Improve mobile reliability and rendering (#340)

This commit is contained in:
Ylber Gashi
2026-03-14 11:45:14 +01:00
committed by GitHub
parent a55c30be61
commit 7d44085a8e
30 changed files with 634 additions and 67 deletions

View File

@@ -0,0 +1,8 @@
{
"extends": ["../../.eslintrc.json"],
"rules": {
"import/namespace": "off",
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off"
}
}

View File

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

View File

@@ -4,7 +4,6 @@ import {
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,

View File

@@ -4,7 +4,6 @@ import {
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, JSONAttributeValue>;
type ReferenceNodeType = 'page' | 'folder' | 'database';
interface JSONContent {
type?: string;
text?: string;
attrs?: Record<string, any>;
marks?: Array<{ type: string; attrs?: Record<string, any> }>;
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 <Text key={i}>{'\n'}</Text>;
}
if (node.type === 'mention') {
const label = getStringAttribute(node.attrs, 'label');
const id = getStringAttribute(node.attrs, 'id');
return (
<Text key={i} style={[mentionStyles.mention, { color: colors.primaryLight }]}>
@{node.attrs?.label ?? node.attrs?.id ?? 'unknown'}
@{label ?? id ?? 'unknown'}
</Text>
);
}
@@ -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 <Text style={style}>{node.text}</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<LocalNode>(userId, nodeId, content.type);
if (!nodeId) {
return null;
}
const canOpen = content.type !== 'database';
const name = node ? getNodeDisplayName(node) : 'Untitled';
const subtitle = getReferenceNodeSubtitle(content);
return (
<Pressable
disabled={!canOpen}
onPress={() => {
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,
]}
>
<NodeIcon type={content.type} size={18} />
<View style={styles.referenceContent}>
<Text style={[styles.referenceTitle, { color: colors.text }]} numberOfLines={1}>
{name}
</Text>
<Text
style={[styles.referenceSubtitle, { color: colors.textMuted }]}
numberOfLines={1}
>
{subtitle}
</Text>
</View>
</Pressable>
);
};
const navigateToFile = (router: ReturnType<typeof useRouter>, 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 (
<View
style={[
styles.imagePreviewCard,
{ backgroundColor: colors.surface, borderColor: colors.border },
]}
>
<View style={styles.imagePreviewStatus}>
{loading ? (
<ActivityIndicator size="small" color={colors.primary} />
) : (
<NodeIcon type="file" size={22} />
)}
<Text
style={[styles.imagePreviewStatusText, { color: colors.textMuted }]}
numberOfLines={2}
>
{subtitle}
</Text>
</View>
<View
style={[
styles.imagePreviewCaption,
{ borderTopColor: colors.border },
]}
>
<Text
style={[styles.referenceTitle, { color: colors.text }]}
numberOfLines={1}
>
{fileName}
</Text>
</View>
</View>
);
};
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 = (
<View
style={[
styles.imagePreviewCard,
{ backgroundColor: colors.surface, borderColor: colors.border },
]}
>
<Image
source={{ uri: localFile.url ?? localFile.path }}
style={styles.imagePreview}
resizeMode="contain"
/>
<View
style={[
styles.imagePreviewCaption,
{ borderTopColor: colors.border },
]}
>
<Text
style={[styles.referenceTitle, { color: colors.text }]}
numberOfLines={1}
>
{fileName}
</Text>
</View>
</View>
);
} else if (file.status !== FileStatus.Ready) {
content = (
<ImagePreviewPlaceholder
fileName={fileName}
subtitle="Image upload pending"
/>
);
} else if (localFile?.downloadStatus === DownloadStatus.Failed) {
content = (
<ImagePreviewPlaceholder
fileName={fileName}
subtitle="Image preview unavailable"
/>
);
} else {
content = (
<ImagePreviewPlaceholder
fileName={fileName}
subtitle={`Loading image preview${progress}`}
loading
/>
);
}
return (
<Pressable
onPress={() => navigateToFile(router, file.id)}
style={({ pressed }) => [
styles.imageBlock,
pressed && styles.referenceBlockPressed,
]}
>
{content}
</Pressable>
);
};
const FileBlockRenderer = ({ content }: { content: JSONContent }) => {
const router = useRouter();
const { colors } = useTheme();
const { userId } = useWorkspace();
const fileId = getStringAttribute(content.attrs, 'id');
const { data: file } = useNodeQuery<LocalFileNode>(userId, fileId, 'file');
if (!fileId) {
return null;
}
if (file?.subtype === 'image') {
return <ImageFileBlockRenderer file={file} />;
}
return (
<Pressable
onPress={() => navigateToFile(router, fileId)}
style={({ pressed }) => [styles.fileBlock, pressed && styles.fileBlockPressed]}
>
{file ? (
<FileItem file={file} />
) : (
<View
style={[
styles.fileFallback,
{ backgroundColor: colors.surface, borderColor: colors.border },
]}
>
<Text style={styles.fileIcon}>{'\u{1F4CE}'}</Text>
<Text style={[styles.fileLabel, { color: colors.text }]}>
Attached file
</Text>
</View>
)}
</Pressable>
);
};
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 (
<View
style={[
styles.referenceBlock,
{ backgroundColor: colors.surface, borderColor: colors.border },
]}
>
<NodeIcon type="file" size={18} />
<View style={styles.referenceContent}>
<Text style={[styles.referenceTitle, { color: colors.text }]} numberOfLines={1}>
{tempFile?.name ?? 'Uploading file'}
</Text>
<Text
style={[styles.referenceSubtitle, { color: colors.textMuted }]}
numberOfLines={1}
>
{tempFile?.subtype ? `${tempFile.subtype} upload` : 'Pending upload'}
</Text>
</View>
</View>
);
};
export const BlockRenderer = ({ content }: BlockRendererProps) => {
const { colors } = useTheme();
@@ -103,7 +532,7 @@ export const BlockRenderer = ({ content }: BlockRendererProps) => {
return (
<View>
{content.content?.map((child, i) => (
<BlockRenderer key={child.attrs?.id ?? i} content={child} />
<BlockRenderer key={getNodeKey(child, i)} content={child} />
))}
</View>
);
@@ -119,8 +548,11 @@ export const BlockRenderer = ({ content }: BlockRendererProps) => {
</View>
);
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 (
<View style={styles.heading}>
@@ -131,15 +563,26 @@ export const BlockRenderer = ({ content }: BlockRendererProps) => {
);
}
case 'file':
return <FileBlockRenderer content={content} />;
case 'page':
case 'folder':
case 'database':
return <ReferenceNodeBlockRenderer content={content} />;
case 'tempFile':
return <TempFileBlockRenderer content={content} />;
case 'bulletList':
return (
<View style={styles.list}>
{content.content?.map((child, i) => (
<View key={child.attrs?.id ?? i} style={styles.listItemRow}>
<View key={getNodeKey(child, i)} style={styles.listItemRow}>
<Text style={[styles.bullet, { color: colors.textSecondary }]}>{'\u2022'}</Text>
<View style={styles.listItemContent}>
{child.content?.map((inner, j) => (
<BlockRenderer key={inner.attrs?.id ?? j} content={inner} />
<BlockRenderer key={getNodeKey(inner, j)} content={inner} />
))}
</View>
</View>
@@ -151,11 +594,11 @@ export const BlockRenderer = ({ content }: BlockRendererProps) => {
return (
<View style={styles.list}>
{content.content?.map((child, i) => (
<View key={child.attrs?.id ?? i} style={styles.listItemRow}>
<View key={getNodeKey(child, i)} style={styles.listItemRow}>
<Text style={[styles.orderedNumber, { color: colors.textSecondary }]}>{i + 1}.</Text>
<View style={styles.listItemContent}>
{child.content?.map((inner, j) => (
<BlockRenderer key={inner.attrs?.id ?? j} content={inner} />
<BlockRenderer key={getNodeKey(inner, j)} content={inner} />
))}
</View>
</View>
@@ -167,7 +610,7 @@ export const BlockRenderer = ({ content }: BlockRendererProps) => {
return (
<View style={styles.list}>
{content.content?.map((child, i) => (
<TaskItemRenderer key={child.attrs?.id ?? i} content={child} />
<TaskItemRenderer key={getNodeKey(child, i)} content={child} />
))}
</View>
);
@@ -179,7 +622,7 @@ export const BlockRenderer = ({ content }: BlockRendererProps) => {
return (
<View>
{content.content?.map((child, i) => (
<BlockRenderer key={child.attrs?.id ?? i} content={child} />
<BlockRenderer key={getNodeKey(child, i)} content={child} />
))}
</View>
);
@@ -188,7 +631,7 @@ export const BlockRenderer = ({ content }: BlockRendererProps) => {
return (
<View style={[styles.blockquote, { borderLeftColor: colors.sheetHandle }]}>
{content.content?.map((child, i) => (
<BlockRenderer key={child.attrs?.id ?? i} content={child} />
<BlockRenderer key={getNodeKey(child, i)} content={child} />
))}
</View>
);
@@ -204,10 +647,10 @@ export const BlockRenderer = ({ content }: BlockRendererProps) => {
return (
<View style={[tableStyles.table, { borderColor: colors.border }]}>
{content.content?.map((row, i) => (
<View key={row.attrs?.id ?? i} style={[tableStyles.row, { borderBottomColor: colors.border }]}>
<View key={getNodeKey(row, i)} style={[tableStyles.row, { borderBottomColor: colors.border }]}>
{row.content?.map((cell, j) => (
<View
key={cell.attrs?.id ?? j}
key={getNodeKey(cell, j)}
style={[
tableStyles.cell,
{ borderRightColor: colors.border },
@@ -215,7 +658,7 @@ export const BlockRenderer = ({ content }: BlockRendererProps) => {
]}
>
{cell.content?.map((inner, k) => (
<BlockRenderer key={inner.attrs?.id ?? k} content={inner} />
<BlockRenderer key={getNodeKey(inner, k)} content={inner} />
))}
</View>
))}
@@ -230,7 +673,7 @@ export const BlockRenderer = ({ content }: BlockRendererProps) => {
return (
<View>
{content.content?.map((child, i) => (
<BlockRenderer key={child.attrs?.id ?? i} content={child} />
<BlockRenderer key={getNodeKey(child, i)} content={child} />
))}
</View>
);
@@ -242,14 +685,14 @@ export const BlockRenderer = ({ content }: BlockRendererProps) => {
return <Text>{'\n'}</Text>;
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 (
<View style={styles.listItemRow}>
@@ -258,7 +701,7 @@ const TaskItemRenderer = ({ content }: { content: JSONContent }) => {
</Text>
<View style={styles.listItemContent}>
{content.content?.map((inner, j) => (
<BlockRenderer key={inner.attrs?.id ?? j} content={inner} />
<BlockRenderer key={getNodeKey(inner, j)} content={inner} />
))}
</View>
</View>
@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,6 @@ import {
MutationInput,
MutationMap,
} from '@colanode/client/mutations';
import { useAppService } from '@colanode/mobile/contexts/app-service';
interface MutationOptions<T extends MutationInput> {

View File

@@ -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 | number>): string => {

View File

@@ -1,5 +1,4 @@
import { LocalNode } from '@colanode/client/types/nodes';
import { useNodeListQuery } from '@colanode/mobile/hooks/use-node-list-query';
export const useNodeQuery = <T extends LocalNode>(

View File

@@ -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<T extends QueryInput> = Omit<

View File

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

View File

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

View File

@@ -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<string, WorkspaceRadarData> | undefined,

View File

@@ -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": ["**/*"]
}

View File

@@ -268,6 +268,7 @@ interface DownloadTable {
id: ColumnType<string, string, never>;
file_id: ColumnType<string, string, never>;
version: ColumnType<string, string, string>;
type: ColumnType<number, number, number>;
name: ColumnType<string, string, string>;
path: ColumnType<string, string, string>;
size: ColumnType<number, number, number>;

View File

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

View File

@@ -115,8 +115,8 @@ export class FileDownloadJobHandler implements JobHandler<FileDownloadInput> {
}
);
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,

View File

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

View File

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

View File

@@ -52,8 +52,9 @@ export class YDoc {
schema: z.ZodSchema,
object: z.infer<typeof schema>
): 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);