mirror of
https://github.com/colanode/colanode.git
synced 2026-05-18 13:15:12 +02:00
Improve mobile reliability and rendering (#340)
This commit is contained in:
8
apps/mobile/.eslintrc.json
Normal file
8
apps/mobile/.eslintrc.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
MutationInput,
|
||||
MutationMap,
|
||||
} from '@colanode/client/mutations';
|
||||
|
||||
import { useAppService } from '@colanode/mobile/contexts/app-service';
|
||||
|
||||
interface MutationOptions<T extends MutationInput> {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": ["**/*"]
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user