From de3bbfd1c2882142a3b645aa601ef45393ece4fb Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Fri, 7 Jun 2024 07:58:20 +0200 Subject: [PATCH 01/14] style ui --- README.md | 2 +- apps/app/package.json | 1 + apps/app/src/app/list/[listId].tsx | 95 ++++++++------- .../app/src/components/documentInvitation.tsx | 56 +++++---- apps/app/src/components/subtleInput.tsx | 25 ++++ .../src/components/updateDocumentNameForm.tsx | 7 +- .../primitives/checkbox/checkbox.tsx | 101 ++++++++++++++++ .../primitives/checkbox/checkbox.web.tsx | 114 ++++++++++++++++++ .../components/primitives/checkbox/index.ts | 1 + .../components/primitives/checkbox/types.ts | 11 ++ apps/app/src/rnr/components/ui/checkbox.tsx | 34 ++++++ apps/app/src/rnr/components/ui/input.tsx | 14 +-- apps/app/src/rnr/lib/icons/Check.tsx | 4 + apps/app/src/rnr/lib/icons/X.ts | 4 + yarn.lock | 98 +++++++++++++++ 15 files changed, 482 insertions(+), 85 deletions(-) create mode 100644 apps/app/src/components/subtleInput.tsx create mode 100644 apps/app/src/rnr/components/primitives/checkbox/checkbox.tsx create mode 100644 apps/app/src/rnr/components/primitives/checkbox/checkbox.web.tsx create mode 100644 apps/app/src/rnr/components/primitives/checkbox/index.ts create mode 100644 apps/app/src/rnr/components/primitives/checkbox/types.ts create mode 100644 apps/app/src/rnr/components/ui/checkbox.tsx create mode 100644 apps/app/src/rnr/lib/icons/Check.tsx create mode 100644 apps/app/src/rnr/lib/icons/X.ts diff --git a/README.md b/README.md index 3c101dc..e31dcd2 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ TODO better version where the token is also never exposed to the network so not - todo list UI & structure - nav UI structure - logo and colors -- deploy to production +- deploy to production (before move the repo) - figure out how author keys are managed (tabs in serenity and possible change in secsync) - add retry for locker in case write fails (invalid clock) diff --git a/apps/app/package.json b/apps/app/package.json index 5512bcd..64e82ef 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@expo/vector-icons": "^14.0.0", + "@radix-ui/react-checkbox": "^1.0.4", "@react-navigation/native": "^6.0.2", "@tanstack/react-query": "^5.40.0", "@trpc/client": "^11.0.0-rc.382", diff --git a/apps/app/src/app/list/[listId].tsx b/apps/app/src/app/list/[listId].tsx index d5f8354..254d265 100644 --- a/apps/app/src/app/list/[listId].tsx +++ b/apps/app/src/app/list/[listId].tsx @@ -5,9 +5,12 @@ import sodium, { KeyPair } from "react-native-libsodium"; import { useYjsSync } from "secsync-react-yjs"; import * as Yjs from "yjs"; import { Button } from "~/components/ui/button"; +import { Checkbox } from "~/components/ui/checkbox"; import { Input } from "~/components/ui/input"; import { Text } from "~/components/ui/text"; +import { X } from "~/lib/icons/X"; import { DocumentInvitation } from "../../components/documentInvitation"; +import { SubtleInput } from "../../components/subtleInput"; import { UpdateDocumentNameForm } from "../../components/updateDocumentNameForm"; import { useLocker } from "../../hooks/useLocker"; import { useYArray } from "../../hooks/useYArray"; @@ -108,57 +111,61 @@ const List: React.FC = () => { }); return ( - <> + + + - + + setNewTodoText(value)} + value={newTodoText} + autoCapitalize="none" + autoCorrect={false} + autoComplete="off" + /> + + - - - setNewTodoText(value)} - value={newTodoText} - autoCapitalize="none" - autoCorrect={false} - autoComplete="off" - /> - - - - - {todos.map((entry, index) => { - return ( - - - {entry} - - - - ); - })} - - - + { + console.log("checked", index); + }} + /> + + + + ); + })} + ); }; diff --git a/apps/app/src/components/documentInvitation.tsx b/apps/app/src/components/documentInvitation.tsx index da100fb..37ec5b7 100644 --- a/apps/app/src/components/documentInvitation.tsx +++ b/apps/app/src/components/documentInvitation.tsx @@ -1,5 +1,5 @@ import React, { useId } from "react"; -import { Alert } from "react-native"; +import { Alert, View } from "react-native"; import * as sodium from "react-native-libsodium"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; @@ -55,34 +55,32 @@ export const DocumentInvitation: React.FC = ({ } return ( -
-
- {documentInvitationQuery.data ? ( - - You have one invitation link ({documentInvitationQuery.data.token}) - which is {documentInvitationQuery.data.isExpired ? "" : "not "} - expired. - - ) : ( - No invitation link found - )} + + {documentInvitationQuery.data ? ( + + You have one invitation link ({documentInvitationQuery.data.token}) + which is {documentInvitationQuery.data.isExpired ? "" : "not "} + expired. + + ) : ( + No invitation link found + )} - - {seed && ( - - )} -
-
+ + {seed && ( + + )} + ); }; diff --git a/apps/app/src/components/subtleInput.tsx b/apps/app/src/components/subtleInput.tsx new file mode 100644 index 0000000..a7593b1 --- /dev/null +++ b/apps/app/src/components/subtleInput.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; +import { TextInput } from "react-native"; +import { cn } from "~/lib/utils"; + +const SubtleInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, placeholderClassName, ...props }, ref) => { + return ( + + ); +}); + +SubtleInput.displayName = "SubtleInput"; + +export { SubtleInput }; diff --git a/apps/app/src/components/updateDocumentNameForm.tsx b/apps/app/src/components/updateDocumentNameForm.tsx index 5c5be9c..046365f 100644 --- a/apps/app/src/components/updateDocumentNameForm.tsx +++ b/apps/app/src/components/updateDocumentNameForm.tsx @@ -2,11 +2,11 @@ import { useQueryClient } from "@tanstack/react-query"; import { getQueryKey } from "@trpc/react-query"; import { useEffect, useState } from "react"; import { Alert, View } from "react-native"; -import { Input } from "~/components/ui/input"; import { decryptString } from "../utils/decryptString"; import { documentNameStorage } from "../utils/documentStorage"; import { encryptString } from "../utils/encryptString"; import { trpc } from "../utils/trpc"; +import { SubtleInput } from "./subtleInput"; type Props = { documentId: string; @@ -63,9 +63,8 @@ export const UpdateDocumentNameForm = ({ documentId, documentKey }: Props) => { }; return ( - - + (null); + +const Root = React.forwardRef( + ({ asChild, disabled = false, checked, onCheckedChange, nativeID, ...props }, ref) => { + return ( + + + + ); + } +); + +Root.displayName = 'RootNativeCheckbox'; + +function useCheckboxContext() { + const context = React.useContext(CheckboxContext); + if (!context) { + throw new Error( + 'Checkbox compound components cannot be rendered outside the Checkbox component' + ); + } + return context; +} + +const Trigger = React.forwardRef( + ({ asChild, onPress: onPressProp, ...props }, ref) => { + const { disabled, checked, onCheckedChange, nativeID } = useCheckboxContext(); + + function onPress(ev: GestureResponderEvent) { + if (disabled) return; + const newValue = !checked; + onCheckedChange(newValue); + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +Trigger.displayName = 'TriggerNativeCheckbox'; + +const Indicator = React.forwardRef< + React.ElementRef, + ComponentPropsWithAsChild & CheckboxIndicator +>(({ asChild, forceMount, ...props }, ref) => { + const { checked, disabled } = useCheckboxContext(); + + if (!forceMount) { + if (!checked) { + return null; + } + } + + const Component = asChild ? Slot.View : View; + return ( + + ); +}); + +Indicator.displayName = 'IndicatorNativeCheckbox'; + +export { Indicator, Root }; diff --git a/apps/app/src/rnr/components/primitives/checkbox/checkbox.web.tsx b/apps/app/src/rnr/components/primitives/checkbox/checkbox.web.tsx new file mode 100644 index 0000000..661ead3 --- /dev/null +++ b/apps/app/src/rnr/components/primitives/checkbox/checkbox.web.tsx @@ -0,0 +1,114 @@ +import * as Checkbox from '@radix-ui/react-checkbox'; +import * as React from 'react'; +import { GestureResponderEvent, Pressable, View } from 'react-native'; +import { useAugmentedRef } from '~/components/primitives/hooks'; +import * as Slot from '~/components/primitives/slot'; +import type { ComponentPropsWithAsChild, PressableRef, SlottablePressableProps } from '~/components/primitives/types'; +import type { CheckboxIndicator, CheckboxRootProps } from './types'; + +const CheckboxContext = React.createContext(null); + +const Root = React.forwardRef( + ( + { asChild, disabled, checked, onCheckedChange, onPress: onPressProp, role: _role, ...props }, + ref + ) => { + const augmentedRef = useAugmentedRef({ ref }); + + function onPress(ev: GestureResponderEvent) { + onPressProp?.(ev); + onCheckedChange(!checked); + } + + React.useLayoutEffect(() => { + if (augmentedRef.current) { + const augRef = augmentedRef.current as unknown as HTMLButtonElement; + augRef.dataset.state = checked ? 'checked' : 'unchecked'; + augRef.value = checked ? 'on' : 'off'; + } + }, [checked]); + + React.useLayoutEffect(() => { + if (augmentedRef.current) { + const augRef = augmentedRef.current as unknown as HTMLButtonElement; + augRef.type = 'button'; + augRef.role = 'checkbox'; + + if (disabled) { + augRef.dataset.disabled = 'true'; + } else { + augRef.dataset.disabled = undefined; + } + } + }, [disabled]); + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + + + + + ); + } +); + +Root.displayName = 'RootWebCheckbox'; + +function useCheckboxContext() { + const context = React.useContext(CheckboxContext); + if (context === null) { + throw new Error( + 'Checkbox compound components cannot be rendered outside the Checkbox component' + ); + } + return context; +} + +const Indicator = React.forwardRef< + React.ElementRef, + ComponentPropsWithAsChild & CheckboxIndicator +>(({ asChild, forceMount, ...props }, ref) => { + const { checked, disabled } = useCheckboxContext(); + const augmentedRef = useAugmentedRef({ ref }); + + React.useLayoutEffect(() => { + if (augmentedRef.current) { + const augRef = augmentedRef.current as unknown as HTMLDivElement; + augRef.dataset.state = checked ? 'checked' : 'unchecked'; + } + }, [checked]); + + React.useLayoutEffect(() => { + if (augmentedRef.current) { + const augRef = augmentedRef.current as unknown as HTMLDivElement; + if (disabled) { + augRef.dataset.disabled = 'true'; + } else { + augRef.dataset.disabled = undefined; + } + } + }, [disabled]); + + const Component = asChild ? Slot.View : View; + return ( + + + + ); +}); + +Indicator.displayName = 'IndicatorWebCheckbox'; + +export { Indicator, Root }; diff --git a/apps/app/src/rnr/components/primitives/checkbox/index.ts b/apps/app/src/rnr/components/primitives/checkbox/index.ts new file mode 100644 index 0000000..8d78b3e --- /dev/null +++ b/apps/app/src/rnr/components/primitives/checkbox/index.ts @@ -0,0 +1 @@ +export * from './checkbox'; diff --git a/apps/app/src/rnr/components/primitives/checkbox/types.ts b/apps/app/src/rnr/components/primitives/checkbox/types.ts new file mode 100644 index 0000000..c3bf2bc --- /dev/null +++ b/apps/app/src/rnr/components/primitives/checkbox/types.ts @@ -0,0 +1,11 @@ +import type { ForceMountable } from '~/components/primitives/types'; + +interface CheckboxRootProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + disabled?: boolean; +} + +type CheckboxIndicator = ForceMountable; + +export type { CheckboxRootProps, CheckboxIndicator }; diff --git a/apps/app/src/rnr/components/ui/checkbox.tsx b/apps/app/src/rnr/components/ui/checkbox.tsx new file mode 100644 index 0000000..99e700f --- /dev/null +++ b/apps/app/src/rnr/components/ui/checkbox.tsx @@ -0,0 +1,34 @@ +import * as CheckboxPrimitive from '~/components/primitives/checkbox'; +import * as React from 'react'; +import { Check } from '~/lib/icons/Check'; + +import { Platform } from 'react-native'; +import { cn } from '~/lib/utils'; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + + + + + ); +}); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/apps/app/src/rnr/components/ui/input.tsx b/apps/app/src/rnr/components/ui/input.tsx index afc652d..36f5b5a 100644 --- a/apps/app/src/rnr/components/ui/input.tsx +++ b/apps/app/src/rnr/components/ui/input.tsx @@ -1,6 +1,6 @@ -import * as React from 'react'; -import { TextInput } from 'react-native'; -import { cn } from '~/lib/utils'; +import * as React from "react"; +import { TextInput } from "react-native"; +import { cn } from "~/lib/utils"; const Input = React.forwardRef< React.ElementRef, @@ -10,16 +10,16 @@ const Input = React.forwardRef< ); }); -Input.displayName = 'Input'; +Input.displayName = "Input"; export { Input }; diff --git a/apps/app/src/rnr/lib/icons/Check.tsx b/apps/app/src/rnr/lib/icons/Check.tsx new file mode 100644 index 0000000..6c56d6d --- /dev/null +++ b/apps/app/src/rnr/lib/icons/Check.tsx @@ -0,0 +1,4 @@ +import { Check } from 'lucide-react-native'; +import { iconWithClassName } from './iconWithClassName'; +iconWithClassName(Check); +export { Check }; \ No newline at end of file diff --git a/apps/app/src/rnr/lib/icons/X.ts b/apps/app/src/rnr/lib/icons/X.ts new file mode 100644 index 0000000..45e8f9e --- /dev/null +++ b/apps/app/src/rnr/lib/icons/X.ts @@ -0,0 +1,4 @@ +import { X } from "lucide-react-native"; +import { iconWithClassName } from "./iconWithClassName"; +iconWithClassName(X); +export { X }; diff --git a/yarn.lock b/yarn.lock index dad34a2..69e33fb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1676,6 +1676,28 @@ dependencies: "@prisma/debug" "5.14.0" +"@radix-ui/primitive@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.0.1.tgz#e46f9958b35d10e9f6dc71c497305c22e3e55dbd" + integrity sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw== + dependencies: + "@babel/runtime" "^7.13.10" + +"@radix-ui/react-checkbox@^1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-checkbox/-/react-checkbox-1.0.4.tgz#98f22c38d5010dd6df4c5744cac74087e3275f4b" + integrity sha512-CBuGQa52aAYnADZVt/KBQzXrwx6TqnlwtcIPGtVt5JkkzQwMOLJjPukimhfKEr4GQNd43C+djUh5Ikopj8pSLg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-presence" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-use-previous" "1.0.1" + "@radix-ui/react-use-size" "1.0.1" + "@radix-ui/react-compose-refs@1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz#37595b1f16ec7f228d698590e78eeed18ff218ae" @@ -1683,6 +1705,37 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-compose-refs@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989" + integrity sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw== + dependencies: + "@babel/runtime" "^7.13.10" + +"@radix-ui/react-context@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.0.1.tgz#fe46e67c96b240de59187dcb7a1a50ce3e2ec00c" + integrity sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg== + dependencies: + "@babel/runtime" "^7.13.10" + +"@radix-ui/react-presence@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.0.1.tgz#491990ba913b8e2a5db1b06b203cb24b5cdef9ba" + integrity sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-use-layout-effect" "1.0.1" + +"@radix-ui/react-primitive@1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz#d49ea0f3f0b2fe3ab1cb5667eb03e8b843b914d0" + integrity sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-slot" "1.0.2" + "@radix-ui/react-slot@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.1.tgz#e7868c669c974d649070e9ecbec0b367ee0b4d81" @@ -1691,6 +1744,51 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-compose-refs" "1.0.0" +"@radix-ui/react-slot@1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab" + integrity sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-compose-refs" "1.0.1" + +"@radix-ui/react-use-callback-ref@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz#f4bb1f27f2023c984e6534317ebc411fc181107a" + integrity sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ== + dependencies: + "@babel/runtime" "^7.13.10" + +"@radix-ui/react-use-controllable-state@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz#ecd2ced34e6330caf89a82854aa2f77e07440286" + integrity sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-use-callback-ref" "1.0.1" + +"@radix-ui/react-use-layout-effect@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz#be8c7bc809b0c8934acf6657b577daf948a75399" + integrity sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ== + dependencies: + "@babel/runtime" "^7.13.10" + +"@radix-ui/react-use-previous@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-1.0.1.tgz#b595c087b07317a4f143696c6a01de43b0d0ec66" + integrity sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw== + dependencies: + "@babel/runtime" "^7.13.10" + +"@radix-ui/react-use-size@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-size/-/react-use-size-1.0.1.tgz#1c5f5fea940a7d7ade77694bb98116fb49f870b2" + integrity sha512-ibay+VqrgcaI6veAojjofPATwledXiSmX+C0KrBk/xgpX9rBzPV3OsfwlhQdUOFbh+LKQorLYT+xTXW9V8yd0g== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-use-layout-effect" "1.0.1" + "@react-native-community/cli-clean@13.6.6": version "13.6.6" resolved "https://registry.yarnpkg.com/@react-native-community/cli-clean/-/cli-clean-13.6.6.tgz#87c7ad8746c38dab0fe7b3c6ff89d44351d5d943" From 3da20d75282041fc34a2835067d4626b09d8a6d8 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Fri, 7 Jun 2024 08:03:33 +0200 Subject: [PATCH 02/14] style list name --- apps/app/src/components/updateDocumentNameForm.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/updateDocumentNameForm.tsx b/apps/app/src/components/updateDocumentNameForm.tsx index 046365f..f5f9362 100644 --- a/apps/app/src/components/updateDocumentNameForm.tsx +++ b/apps/app/src/components/updateDocumentNameForm.tsx @@ -65,7 +65,8 @@ export const UpdateDocumentNameForm = ({ documentId, documentKey }: Props) => { return ( Date: Fri, 7 Jun 2024 08:49:40 +0200 Subject: [PATCH 03/14] more styling --- apps/app/package.json | 1 + apps/app/src/app/_layout.tsx | 37 +++++- apps/app/src/app/index.tsx | 79 +----------- apps/app/src/components/createListForm.tsx | 116 ++++++++---------- apps/app/src/components/drawerContent.tsx | 94 ++++++++++++++ apps/app/src/components/logout.tsx | 1 + .../src/components/updateDocumentNameForm.tsx | 2 +- apps/app/src/hooks/useIsPermanentDrawer.ts | 8 ++ apps/app/src/rnr/lib/icons/ListTodo.ts | 4 + yarn.lock | 9 ++ 10 files changed, 208 insertions(+), 143 deletions(-) create mode 100644 apps/app/src/components/drawerContent.tsx create mode 100644 apps/app/src/hooks/useIsPermanentDrawer.ts create mode 100644 apps/app/src/rnr/lib/icons/ListTodo.ts diff --git a/apps/app/package.json b/apps/app/package.json index 64e82ef..70e1916 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -14,6 +14,7 @@ "dependencies": { "@expo/vector-icons": "^14.0.0", "@radix-ui/react-checkbox": "^1.0.4", + "@react-navigation/drawer": "^6.6.15", "@react-navigation/native": "^6.0.2", "@tanstack/react-query": "^5.40.0", "@trpc/client": "^11.0.0-rc.382", diff --git a/apps/app/src/app/_layout.tsx b/apps/app/src/app/_layout.tsx index b1636ed..fe41766 100644 --- a/apps/app/src/app/_layout.tsx +++ b/apps/app/src/app/_layout.tsx @@ -6,13 +6,18 @@ import { QueryClientProvider, } from "@tanstack/react-query"; import { httpBatchLink } from "@trpc/client"; -import { SplashScreen, Stack } from "expo-router"; +import { SplashScreen } from "expo-router"; +import { Drawer } from "expo-router/drawer"; import { StatusBar } from "expo-status-bar"; import { useEffect, useState } from "react"; +import { useWindowDimensions } from "react-native"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { NAV_THEME } from "~/lib/constants"; import { useColorScheme } from "~/lib/useColorScheme"; +import { DrawerContent } from "../components/drawerContent"; import "../global.css"; +import { useIsPermanentLeftDrawer } from "../hooks/useIsPermanentDrawer"; import useLoadingLibsodium from "../hooks/useLoadingLibsodium"; import { trpc } from "../utils/trpc"; @@ -98,6 +103,11 @@ export default function Layout() { }) ); + const isPermanentLeftDrawer = useIsPermanentLeftDrawer(); + const { width: fullWidth } = useWindowDimensions(); + + console.log(isPermanentLeftDrawer); + if (!isLoadingComplete) { return null; } @@ -108,7 +118,7 @@ export default function Layout() { - + {/* - + */} + + + + + {/* Default Portal Host (one per app) */} {/* */} diff --git a/apps/app/src/app/index.tsx b/apps/app/src/app/index.tsx index 9ff5934..b9c7510 100644 --- a/apps/app/src/app/index.tsx +++ b/apps/app/src/app/index.tsx @@ -1,84 +1,13 @@ -import { Link } from "expo-router"; import * as React from "react"; import { View } from "react-native"; -import sodium from "react-native-libsodium"; -import { Card } from "~/components/ui/card"; import { Text } from "~/components/ui/text"; -import { CreateListForm } from "../components/createListForm"; -import { Logout } from "../components/logout"; -import { useLocker } from "../hooks/useLocker"; -import { decryptString } from "../utils/decryptString"; -import { documentNameStorage } from "../utils/documentStorage"; -import { trpc } from "../utils/trpc"; - -const Lists: React.FC = () => { - const meQuery = trpc.me.useQuery(undefined, { - // avoid lot's of retries in case of unauthorized blocking a page load - retry: (failureCount, error) => { - if (error.data?.code === "UNAUTHORIZED") { - return false; - } - if (failureCount > 3) return false; - return true; - }, - }); - - const locker = useLocker(); - - const documentsQuery = trpc.documents.useQuery(undefined, { - refetchInterval: 5000, - }); - let keys = documentNameStorage.getAllKeys(); - if (documentsQuery.data) { - const remoteDocumentIds = documentsQuery.data.map((doc) => doc.id); - // merge remote and local keys and deduplicate them - keys = Array.from(new Set([...keys, ...remoteDocumentIds])); - } +const Home: React.FC = () => { return ( - - - Login - - Register - - - {meQuery.data?.username} - - - - - - - - {keys.map((docId) => { - if (!locker.content[`document:${docId}`]) { - return null; - } - const documentKey = sodium.from_base64( - locker.content[`document:${docId}`] - ); - const doc = documentsQuery.data?.find((doc) => doc.id === docId); - const name = doc - ? decryptString({ - ciphertext: doc.nameCiphertext, - commitment: doc.nameCommitment, - nonce: doc.nameNonce, - key: documentKey, - }) - : documentNameStorage.getString(docId); - - return ( - - - {name} - - - ); - })} - + + Welcome 🤗 ); }; -export default Lists; +export default Home; diff --git a/apps/app/src/components/createListForm.tsx b/apps/app/src/components/createListForm.tsx index 2814431..836518d 100644 --- a/apps/app/src/components/createListForm.tsx +++ b/apps/app/src/components/createListForm.tsx @@ -1,7 +1,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { getQueryKey } from "@trpc/react-query"; import { router } from "expo-router"; -import { Alert, View } from "react-native"; +import { Alert } from "react-native"; import * as sodium from "react-native-libsodium"; import { generateId } from "secsync"; import { Button } from "~/components/ui/button"; @@ -12,76 +12,64 @@ import { encryptString } from "../utils/encryptString"; import { trpc } from "../utils/trpc"; export const CreateListForm: React.FC = () => { - // const [name, setName] = useState(""); const createDocumentMutation = trpc.createDocument.useMutation(); const { addItem } = useLocker(); const queryClient = useQueryClient(); return ( - - {/* { - setName(value); - }} - /> */} - - + router.navigate({ + pathname: `/list/[documentId]`, + params: { documentId: document.id }, + }); + const documentsQueryKey = getQueryKey( + trpc.documents, + undefined, + "query" + ); + queryClient.invalidateQueries({ + queryKey: [documentsQueryKey], + }); + }, + onError: () => { + Alert.alert("Failed to create the list"); + }, + } + ); + }} + > + Create new List + ); }; diff --git a/apps/app/src/components/drawerContent.tsx b/apps/app/src/components/drawerContent.tsx new file mode 100644 index 0000000..eef3ef1 --- /dev/null +++ b/apps/app/src/components/drawerContent.tsx @@ -0,0 +1,94 @@ +import { Link } from "expo-router"; +import { ListTodo } from "lucide-react-native"; +import * as React from "react"; +import { View } from "react-native"; +import sodium from "react-native-libsodium"; +import { Text } from "~/components/ui/text"; +import { CreateListForm } from "../components/createListForm"; +import { Logout } from "../components/logout"; +import { useLocker } from "../hooks/useLocker"; +import { decryptString } from "../utils/decryptString"; +import { documentNameStorage } from "../utils/documentStorage"; +import { trpc } from "../utils/trpc"; + +export const DrawerContent: React.FC = () => { + const meQuery = trpc.me.useQuery(undefined, { + // avoid lot's of retries in case of unauthorized blocking a page load + retry: (failureCount, error) => { + if (error.data?.code === "UNAUTHORIZED") { + return false; + } + if (failureCount > 3) return false; + return true; + }, + }); + + const locker = useLocker(); + + const documentsQuery = trpc.documents.useQuery(undefined, { + refetchInterval: 5000, + }); + let keys = documentNameStorage.getAllKeys(); + if (documentsQuery.data) { + const remoteDocumentIds = documentsQuery.data.map((doc) => doc.id); + // merge remote and local keys and deduplicate them + keys = Array.from(new Set([...keys, ...remoteDocumentIds])); + } + + return ( + + + + Login + + Register + + + {meQuery.data?.username} + + + + + + + + {keys.map((docId) => { + if (!locker.content[`document:${docId}`]) { + return null; + } + const documentKey = sodium.from_base64( + locker.content[`document:${docId}`] + ); + const doc = documentsQuery.data?.find((doc) => doc.id === docId); + const name = doc + ? decryptString({ + ciphertext: doc.nameCiphertext, + commitment: doc.nameCommitment, + nonce: doc.nameNonce, + key: documentKey, + }) + : documentNameStorage.getString(docId); + + return ( + + + + + + {name} + + + ); + })} + + + + + + + ); +}; diff --git a/apps/app/src/components/logout.tsx b/apps/app/src/components/logout.tsx index eca1f96..7208026 100644 --- a/apps/app/src/components/logout.tsx +++ b/apps/app/src/components/logout.tsx @@ -14,6 +14,7 @@ export const Logout: React.FC = () => { diff --git a/apps/app/src/components/documentInvitation.tsx b/apps/app/src/components/documentInvitation.tsx index 37ec5b7..35c167c 100644 --- a/apps/app/src/components/documentInvitation.tsx +++ b/apps/app/src/components/documentInvitation.tsx @@ -55,7 +55,7 @@ export const DocumentInvitation: React.FC = ({ } return ( - + {documentInvitationQuery.data ? ( You have one invitation link ({documentInvitationQuery.data.token}) @@ -77,7 +77,6 @@ export const DocumentInvitation: React.FC = ({ id={id} value={`${window.location.origin}/list-invitation/${documentInvitationQuery.data?.token}#key=${seed}`} readOnly - className="w-72 h-40" multiline /> )} diff --git a/apps/app/src/components/drawerContent.tsx b/apps/app/src/components/drawerContent.tsx index 0de67a2..401e312 100644 --- a/apps/app/src/components/drawerContent.tsx +++ b/apps/app/src/components/drawerContent.tsx @@ -49,12 +49,7 @@ export const DrawerContent: React.FC = () => { }} > - - Login - - Register - - + {meQuery.data?.username.substring(0, 2)} @@ -93,7 +88,7 @@ export const DrawerContent: React.FC = () => { - {name} + {name || "Untitled"} ); From 511e396662c3439f65a3bdbe3b626cb11b09bab2 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Fri, 7 Jun 2024 16:23:47 +0200 Subject: [PATCH 06/14] improve styling --- README.md | 12 +++-------- apps/app/src/app/(app)/_layout.tsx | 23 ++++++++++++++++++---- apps/app/src/app/(app)/list/[listId].tsx | 2 +- apps/app/src/components/createListForm.tsx | 2 +- apps/app/src/components/drawerContent.tsx | 20 +++++++++---------- apps/app/src/components/subtleInput.tsx | 2 +- apps/app/src/rnr/components/ui/input.tsx | 2 +- 7 files changed, 35 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index e4d1813..74b94d9 100644 --- a/README.md +++ b/README.md @@ -117,15 +117,9 @@ TODO better version where the token is also never exposed to the network so not ## Todos -- styling - - - implement todos properly in yjs - - fix font-size for inputs - - fix icons and font-size on ios - - fix input active for iOS - - fix iOS of add item - - show members and invitation link as menu - - proper login/signup redirect +- implement todos properly in yjs +- show members and invitation link as menu +- proper login/signup redirect - use expo-secure-store for the sessionKey - encrypt MMKV storage on iOS and Android diff --git a/apps/app/src/app/(app)/_layout.tsx b/apps/app/src/app/(app)/_layout.tsx index e0b7bf7..939c3de 100644 --- a/apps/app/src/app/(app)/_layout.tsx +++ b/apps/app/src/app/(app)/_layout.tsx @@ -1,12 +1,16 @@ +import { DrawerActions } from "@react-navigation/native"; +import { useNavigation } from "expo-router"; import { Drawer } from "expo-router/drawer"; -import { useWindowDimensions } from "react-native"; +import { Pressable, useWindowDimensions } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { PanelLeft } from "~/lib/icons/PanelLeft"; import { DrawerContent } from "../../components/drawerContent"; import { useIsPermanentLeftDrawer } from "../../hooks/useIsPermanentDrawer"; export default function Layout() { const isPermanentLeftDrawer = useIsPermanentLeftDrawer(); const { width: fullWidth } = useWindowDimensions(); + const navigation = useNavigation(); return ( @@ -20,9 +24,20 @@ export default function Layout() { overlayColor: "transparent", drawerPosition: "left", headerShown: isPermanentLeftDrawer ? false : true, - // headerLeft: () => { - // return ; - // }, + headerLeft: () => { + const navigation = useNavigation(); + + return ( + + navigation.dispatch(DrawerActions.toggleDrawer()) + } + > + + + ); + }, headerTitle: () => null, // drawerStyle: { // width: 240, diff --git a/apps/app/src/app/(app)/list/[listId].tsx b/apps/app/src/app/(app)/list/[listId].tsx index 00da562..8fcc24a 100644 --- a/apps/app/src/app/(app)/list/[listId].tsx +++ b/apps/app/src/app/(app)/list/[listId].tsx @@ -172,7 +172,7 @@ const List: React.FC = () => { yTodos.delete(index, 1); }} > - + ); diff --git a/apps/app/src/components/createListForm.tsx b/apps/app/src/components/createListForm.tsx index 836518d..044ece2 100644 --- a/apps/app/src/components/createListForm.tsx +++ b/apps/app/src/components/createListForm.tsx @@ -69,7 +69,7 @@ export const CreateListForm: React.FC = () => { ); }} > - Create new List + Create List ); }; diff --git a/apps/app/src/components/drawerContent.tsx b/apps/app/src/components/drawerContent.tsx index 401e312..06901e3 100644 --- a/apps/app/src/components/drawerContent.tsx +++ b/apps/app/src/components/drawerContent.tsx @@ -79,17 +79,15 @@ export const DrawerContent: React.FC = () => { : documentNameStorage.getString(docId); return ( - - - - - - {name || "Untitled"} - + + + + + + + {name || "Untitled"} + + ); })} diff --git a/apps/app/src/components/subtleInput.tsx b/apps/app/src/components/subtleInput.tsx index a7593b1..af85bfa 100644 --- a/apps/app/src/components/subtleInput.tsx +++ b/apps/app/src/components/subtleInput.tsx @@ -10,7 +10,7 @@ const SubtleInput = React.forwardRef< Date: Fri, 7 Jun 2024 18:41:30 +0200 Subject: [PATCH 07/14] document members and yjs structure --- README.md | 3 +- apps/app/metro.config.js | 7 + apps/app/package.json | 1 + apps/app/src/app/(app)/list/[listId].tsx | 69 +++- apps/app/src/app/_layout.tsx | 3 +- .../app/src/components/documentInvitation.tsx | 3 +- apps/app/src/components/documentMember.tsx | 97 ++++++ apps/app/src/components/logout.tsx | 20 +- .../src/components/updateDocumentNameForm.tsx | 2 +- apps/app/src/hooks/useYData.ts | 26 ++ .../components/primitives/popover/index.ts | 1 + .../components/primitives/popover/popover.tsx | 324 ++++++++++++++++++ .../primitives/popover/popover.web.tsx | 191 +++++++++++ .../components/primitives/popover/types.ts | 24 ++ apps/app/src/rnr/components/ui/popover.tsx | 48 +++ apps/app/src/rnr/lib/icons/Users.ts | 4 + apps/app/src/utils/sessionKeyStorage.ts | 2 +- yarn.lock | 207 +++++++++++ 18 files changed, 1008 insertions(+), 24 deletions(-) create mode 100644 apps/app/src/components/documentMember.tsx create mode 100644 apps/app/src/hooks/useYData.ts create mode 100644 apps/app/src/rnr/components/primitives/popover/index.ts create mode 100644 apps/app/src/rnr/components/primitives/popover/popover.tsx create mode 100644 apps/app/src/rnr/components/primitives/popover/popover.web.tsx create mode 100644 apps/app/src/rnr/components/primitives/popover/types.ts create mode 100644 apps/app/src/rnr/components/ui/popover.tsx create mode 100644 apps/app/src/rnr/lib/icons/Users.ts diff --git a/README.md b/README.md index 74b94d9..06fb40e 100644 --- a/README.md +++ b/README.md @@ -118,8 +118,9 @@ TODO better version where the token is also never exposed to the network so not ## Todos - implement todos properly in yjs -- show members and invitation link as menu +- show members and invitation link as menu (popup broken) - proper login/signup redirect +- on enter submit the add form (also every input) - use expo-secure-store for the sessionKey - encrypt MMKV storage on iOS and Android diff --git a/apps/app/metro.config.js b/apps/app/metro.config.js index 3c80801..a67f5c4 100644 --- a/apps/app/metro.config.js +++ b/apps/app/metro.config.js @@ -13,6 +13,13 @@ const config = getDefaultConfig(__dirname, { isCSSEnabled: true }); config.resolver.unstable_enableSymlinks = true; config.resolver.unstable_enablePackageExports = true; +//.needed for zustand https://github.com/pmndrs/zustand/discussions/1967#discussioncomment-9578159 +config.resolver.unstable_conditionNames = [ + "browser", + "require", + "react-native", +]; + // Needed for monorepo setup (can be removed in standalone projects) const projectRoot = __dirname; const monorepoRoot = path.resolve(projectRoot, "../.."); diff --git a/apps/app/package.json b/apps/app/package.json index 70e1916..e65a50c 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -14,6 +14,7 @@ "dependencies": { "@expo/vector-icons": "^14.0.0", "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-popover": "^1.0.7", "@react-navigation/drawer": "^6.6.15", "@react-navigation/native": "^6.0.2", "@tanstack/react-query": "^5.40.0", diff --git a/apps/app/src/app/(app)/list/[listId].tsx b/apps/app/src/app/(app)/list/[listId].tsx index 8fcc24a..7ca8c19 100644 --- a/apps/app/src/app/(app)/list/[listId].tsx +++ b/apps/app/src/app/(app)/list/[listId].tsx @@ -2,6 +2,7 @@ import { useLocalSearchParams } from "expo-router"; import React, { useEffect, useRef, useState } from "react"; import { View } from "react-native"; import sodium, { KeyPair } from "react-native-libsodium"; +import { generateId } from "secsync"; import { useYjsSync } from "secsync-react-yjs"; import * as Yjs from "yjs"; import { Button } from "~/components/ui/button"; @@ -9,17 +10,18 @@ import { Checkbox } from "~/components/ui/checkbox"; import { Input } from "~/components/ui/input"; import { Text } from "~/components/ui/text"; import { X } from "~/lib/icons/X"; -import { DocumentInvitation } from "../../../components/documentInvitation"; +import { DocumentMembers } from "../../../components/documentMember"; import { SubtleInput } from "../../../components/subtleInput"; import { UpdateDocumentNameForm } from "../../../components/updateDocumentNameForm"; import { useLocker } from "../../../hooks/useLocker"; -import { useYArray } from "../../../hooks/useYArray"; +import { useYData } from "../../../hooks/useYData"; import { deserialize } from "../../../utils/deserialize"; import { documentPendingChangesStorage, documentStorage, } from "../../../utils/documentStorage"; import { serialize } from "../../../utils/serialize"; +import { trpc } from "../../../utils/trpc"; const websocketEndpoint = process.env.NODE_ENV === "development" @@ -30,9 +32,17 @@ type Props = { documentId: string; }; +type ChecklistItem = { + text: string; + checked: boolean; +}; + const List: React.FC = () => { const { listId } = useLocalSearchParams(); const documentId = typeof listId === "string" ? listId : ""; + const getDocumentQuery = trpc.getDocument.useQuery(documentId, { + refetchInterval: 5000, + }); const [authorKeyPair] = useState(() => { return sodium.crypto_sign_keypair(); @@ -71,8 +81,8 @@ const List: React.FC = () => { }; }, []); - const yTodos: Yjs.Array = yDocRef.current.getArray("todos"); - const todos = useYArray(yTodos); + const yDocument: Yjs.Map> = yDocRef.current.getMap("document"); + const document = useYData<{ [k: string]: ChecklistItem }>(yDocument); const [newTodoText, setNewTodoText] = useState(""); const { content } = useLocker(); @@ -111,13 +121,20 @@ const List: React.FC = () => { }); return ( - - + + + + + - + @@ -135,7 +152,15 @@ const List: React.FC = () => { className="add" onPress={(event) => { event.preventDefault(); - yTodos.push([newTodoText]); + // @ts-expect-error sodium is not typed + const id = generateId(sodium); + const text = new Yjs.Text(newTodoText); + const todo = new Yjs.Map(); + todo.set("type", "checklist-item"); + todo.set("text", text); + todo.set("checked", false); + // TODO add position + yDocument.set(id, todo); setNewTodoText(""); }} > @@ -143,33 +168,45 @@ const List: React.FC = () => { - {todos.map((entry, index) => { + {Object.keys(document || {}).map((id, index) => { + if (document === null) return null; + const entry = document[id]; + return ( { - console.log("checked", index); + const yEntry = yDocument.get(id); + if (yEntry) { + yEntry.set("checked", !entry.checked); + } }} /> { + const yEntry = yDocument.get(id); + if (yEntry) { + yEntry.set("text", newText); + } + }} /> + + + {currentUserIsAdmin ? ( + <> + + + + + ) : null} + Members + + {documentMembersQuery.data?.map((user) => { + return ( + + + {user.username.substring(0, 2)} + + + {user.username} + {user.isAdmin ? "(admin)" : ""} + + ); + })} + + + + + ); +}; diff --git a/apps/app/src/components/logout.tsx b/apps/app/src/components/logout.tsx index 7208026..5f027a3 100644 --- a/apps/app/src/components/logout.tsx +++ b/apps/app/src/components/logout.tsx @@ -4,24 +4,38 @@ import { Alert } from "react-native"; import { Button } from "~/components/ui/button"; import { Text } from "~/components/ui/text"; import { lockerStorage } from "../hooks/useLocker"; +import { + documentNameStorage, + documentPendingChangesStorage, + documentStorage, +} from "../utils/documentStorage"; +import { sessionKeyStorage } from "../utils/sessionKeyStorage"; import { trpc } from "../utils/trpc"; export const Logout: React.FC = () => { const logoutMutation = trpc.logout.useMutation(); const queryClient = useQueryClient(); + const clearAllStores = () => { + lockerStorage.clearAll(); + documentNameStorage.clearAll(); + documentStorage.clearAll(); + documentPendingChangesStorage.clearAll(); + sessionKeyStorage.clearAll(); + queryClient.invalidateQueries(); + }; + return ( - {Object.keys(document || {}).map((id, index) => { - if (document === null) return null; - const entry = document[id]; - + {checklist.map((entry, index) => { return ( = () => { { - const yEntry = yDocument.get(id); + const yEntry = yDocument.get(entry.id); if (yEntry) { yEntry.set("checked", !entry.checked); } @@ -195,7 +195,7 @@ const List: React.FC = () => { autoCorrect={false} autoComplete="off" onChangeText={(newText) => { - const yEntry = yDocument.get(id); + const yEntry = yDocument.get(entry.id); if (yEntry) { yEntry.set("text", newText); } @@ -206,10 +206,10 @@ const List: React.FC = () => { variant="ghost" size="icon" onPress={() => { - yDocument.delete(id); + yDocument.delete(entry.id); }} > - + ); diff --git a/apps/app/src/types.ts b/apps/app/src/types.ts new file mode 100644 index 0000000..df23523 --- /dev/null +++ b/apps/app/src/types.ts @@ -0,0 +1,9 @@ +type ChecklistItem = { + text: string; + checked: boolean; + position: string; +}; + +type ChecklistItemWithId = ChecklistItem & { + id: string; +}; diff --git a/apps/app/src/utils/convertChecklistToArrayAndSort.ts b/apps/app/src/utils/convertChecklistToArrayAndSort.ts new file mode 100644 index 0000000..10147ed --- /dev/null +++ b/apps/app/src/utils/convertChecklistToArrayAndSort.ts @@ -0,0 +1,12 @@ +export const convertChecklistToArrayAndSort = (items: { + [key: string]: ChecklistItem; +}) => { + const result: ChecklistItemWithId[] = Object.keys(items).map((key) => ({ + id: key, + ...items[key], + })); + + result.sort((a, b) => a.position.localeCompare(b.position)); + + return result; +}; diff --git a/apps/app/src/utils/position.ts b/apps/app/src/utils/position.ts new file mode 100644 index 0000000..2014f87 --- /dev/null +++ b/apps/app/src/utils/position.ts @@ -0,0 +1,3 @@ +import { PositionSource } from "position-strings"; + +export const position = new PositionSource(); From 29e793f06b2a07bb912ee689534e9fd5d6279452 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Sat, 8 Jun 2024 06:20:44 +0200 Subject: [PATCH 09/14] fix redirect flows --- README.md | 4 +- apps/app/src/app/(app)/_layout.tsx | 11 +++- apps/app/src/app/_layout.tsx | 71 ++++++++++++---------- apps/app/src/components/avatar.tsx | 12 ++++ apps/app/src/components/documentMember.tsx | 21 ++----- apps/app/src/components/drawerContent.tsx | 5 +- apps/app/src/hooks/useLocker.ts | 2 +- 7 files changed, 71 insertions(+), 55 deletions(-) create mode 100644 apps/app/src/components/avatar.tsx diff --git a/README.md b/README.md index a5aa22b..9ba270f 100644 --- a/README.md +++ b/README.md @@ -117,9 +117,7 @@ TODO better version where the token is also never exposed to the network so not ## Todos -- locker bug during registration - show members and invitation link as menu (popup broken) -- proper login/signup redirect - on enter submit the add form (also every input) - fancier checkbox @@ -128,6 +126,8 @@ TODO better version where the token is also never exposed to the network so not - logo and colors - deploy to production (before move the repo) +- locker bug during registration 🤷 + - figure out how author keys are managed (tabs in serenity and possible change in secsync) - add retry for locker in case write fails (invalid clock) - allow to delete list (needs a tombstone and properly cleanup local stores) diff --git a/apps/app/src/app/(app)/_layout.tsx b/apps/app/src/app/(app)/_layout.tsx index 939c3de..63ff9bc 100644 --- a/apps/app/src/app/(app)/_layout.tsx +++ b/apps/app/src/app/(app)/_layout.tsx @@ -1,16 +1,23 @@ import { DrawerActions } from "@react-navigation/native"; -import { useNavigation } from "expo-router"; +import { Redirect, useNavigation, usePathname } from "expo-router"; import { Drawer } from "expo-router/drawer"; import { Pressable, useWindowDimensions } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { PanelLeft } from "~/lib/icons/PanelLeft"; import { DrawerContent } from "../../components/drawerContent"; import { useIsPermanentLeftDrawer } from "../../hooks/useIsPermanentDrawer"; +import { getSessionKey } from "../../utils/sessionKeyStorage"; export default function Layout() { const isPermanentLeftDrawer = useIsPermanentLeftDrawer(); const { width: fullWidth } = useWindowDimensions(); - const navigation = useNavigation(); + const pathname = usePathname(); + + const sessionKey = getSessionKey(); + if (!sessionKey) { + const redirect = pathname !== "/" ? "?redirect=" + pathname : ""; + return ; + } return ( diff --git a/apps/app/src/app/_layout.tsx b/apps/app/src/app/_layout.tsx index 35dda1b..c26b459 100644 --- a/apps/app/src/app/_layout.tsx +++ b/apps/app/src/app/_layout.tsx @@ -5,8 +5,13 @@ import { QueryClient, QueryClientProvider, } from "@tanstack/react-query"; -import { httpBatchLink } from "@trpc/client"; -import { Slot, SplashScreen } from "expo-router"; +import { TRPCClientError, httpBatchLink } from "@trpc/client"; +import { + Slot, + SplashScreen, + router, + useNavigationContainerRef, +} from "expo-router"; import { StatusBar } from "expo-status-bar"; import { useEffect, useState } from "react"; import { SafeAreaProvider } from "react-native-safe-area-context"; @@ -44,42 +49,44 @@ export default function Layout() { } }, [isLoadingComplete]); + const navigationRef = useNavigationContainerRef(); // You can also use a regular ref with `React.useRef()` + const [queryClient] = useState( () => new QueryClient({ queryCache: new QueryCache({ - // TODO - // onError: (error) => { - // if ( - // error instanceof TRPCClientError && - // error.data?.code === "UNAUTHORIZED" && - // window.location.pathname !== "/login" - // ) { - // removeLocalDb(); - // queryClient.clear(); - // router.navigate({ - // to: "/login", - // search: { redirect: window.location.pathname }, - // }); - // } - // }, + onError: (error) => { + if ( + error instanceof TRPCClientError && + error.data?.code === "UNAUTHORIZED" && + !( + navigationRef.getRootState().routes[0].name === "login" || + navigationRef.getRootState().routes[0].name === "register" + ) + ) { + queryClient.clear(); + // const redirect = pathname !== "/" ? "?redirect=" + pathname : ""; + // router.navigate(`/login${redirect}`); + router.navigate(`/`); + } + }, }), mutationCache: new MutationCache({ - // TODO - // onError: (error) => { - // if ( - // error instanceof TRPCClientError && - // error.data?.code === "UNAUTHORIZED" && - // window.location.pathname !== "/login" - // ) { - // removeLocalDb(); - // queryClient.clear(); - // router.navigate({ - // to: "/login", - // search: { redirect: window.location.pathname }, - // }); - // } - // }, + onError: (error) => { + if ( + error instanceof TRPCClientError && + error.data?.code === "UNAUTHORIZED" && + !( + navigationRef.getRootState().routes[0].name === "login" || + navigationRef.getRootState().routes[0].name === "register" + ) + ) { + queryClient.clear(); + // const redirect = pathname !== "/" ? "?redirect=" + pathname : ""; + // router.navigate(`/login${redirect}`); + router.navigate(`/`); + } + }, }), }) ); diff --git a/apps/app/src/components/avatar.tsx b/apps/app/src/components/avatar.tsx new file mode 100644 index 0000000..b3abfd9 --- /dev/null +++ b/apps/app/src/components/avatar.tsx @@ -0,0 +1,12 @@ +import { View } from "react-native"; +import { Text } from "~/components/ui/text"; + +type Props = { name: string }; + +export const Avatar: React.FC = ({ name }) => { + return ( + + {name.substring(0, 2)} + + ); +}; diff --git a/apps/app/src/components/documentMember.tsx b/apps/app/src/components/documentMember.tsx index bf93c8c..69397ba 100644 --- a/apps/app/src/components/documentMember.tsx +++ b/apps/app/src/components/documentMember.tsx @@ -8,6 +8,7 @@ import { import { Text } from "~/components/ui/text"; import { Users } from "~/lib/icons/Users"; import { trpc } from "../utils/trpc"; +import { Avatar } from "./avatar"; import { DocumentInvitation } from "./documentInvitation"; type Props = { @@ -33,23 +34,16 @@ export const DocumentMembers: React.FC = ({ return ( {isPrivateNote ? ( - + Private ) : ( - + {visibleUsers.map((user) => { - return ( - - {user.username.substring(0, 2)} - - ); + return ; })} {moreUsersCount !== 0 && ( - + +{moreUsersCount} )} @@ -80,10 +74,7 @@ export const DocumentMembers: React.FC = ({ key={user.id} className="flex flex-row gap-4 items-center" > - - {user.username.substring(0, 2)} - - + {user.username} {user.isAdmin ? "(admin)" : ""} diff --git a/apps/app/src/components/drawerContent.tsx b/apps/app/src/components/drawerContent.tsx index 06901e3..73203c6 100644 --- a/apps/app/src/components/drawerContent.tsx +++ b/apps/app/src/components/drawerContent.tsx @@ -11,6 +11,7 @@ import { useLocker } from "../hooks/useLocker"; import { decryptString } from "../utils/decryptString"; import { documentNameStorage } from "../utils/documentStorage"; import { trpc } from "../utils/trpc"; +import { Avatar } from "./avatar"; export const DrawerContent: React.FC = () => { const meQuery = trpc.me.useQuery(undefined, { @@ -50,9 +51,7 @@ export const DrawerContent: React.FC = () => { > - - {meQuery.data?.username.substring(0, 2)} - + {meQuery.data?.username} diff --git a/apps/app/src/hooks/useLocker.ts b/apps/app/src/hooks/useLocker.ts index cea5ae4..2cc8709 100644 --- a/apps/app/src/hooks/useLocker.ts +++ b/apps/app/src/hooks/useLocker.ts @@ -45,7 +45,7 @@ export const useLocker = () => { latestUserLockerQuery.data?.clock, ]); - // TODO RETRY in case it fails + // TODO mutation RETRY in case it fails const addItem = async ( params: | { type: "document"; documentId: string; value: string } From 35e179a84cfe9aa21a2ca8681c44b5fb41660171 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Sat, 8 Jun 2024 16:52:53 +0200 Subject: [PATCH 10/14] submit forms on enter --- README.md | 4 +- apps/app/src/app/(app)/list/[listId].tsx | 42 +++++++++---------- apps/app/src/app/login.tsx | 8 ++-- apps/app/src/app/register.tsx | 8 ++-- apps/app/src/components/authForm.tsx | 6 +++ apps/app/src/components/createListForm.tsx | 2 +- .../src/components/updateDocumentNameForm.tsx | 10 +++++ 7 files changed, 50 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 9ba270f..2951509 100644 --- a/README.md +++ b/README.md @@ -117,9 +117,9 @@ TODO better version where the token is also never exposed to the network so not ## Todos -- show members and invitation link as menu (popup broken) -- on enter submit the add form (also every input) - fancier checkbox +- create a new invitation needs a change in react-native-libsodium (use noble?) +- on new list focus on the name input - use expo-secure-store for the sessionKey - encrypt MMKV storage on iOS and Android diff --git a/apps/app/src/app/(app)/list/[listId].tsx b/apps/app/src/app/(app)/list/[listId].tsx index 2c45cac..c24c432 100644 --- a/apps/app/src/app/(app)/list/[listId].tsx +++ b/apps/app/src/app/(app)/list/[listId].tsx @@ -118,6 +118,25 @@ const List: React.FC = () => { logging: "debug", }); + const addChecklistItem = (event: any) => { + event.preventDefault(); + // @ts-expect-error sodium is not typed + const id = generateId(sodium); + const text = new Yjs.Text(newTodoText); + const todo = new Yjs.Map(); + const newPosition = + checklist.length > 0 + ? position.createBetween(undefined, checklist[0].position) + : position.createBetween(); + todo.set("type", "checklist-item"); + todo.set("text", text); + todo.set("checked", false); + todo.set("position", newPosition); + + yDocument.set(id, todo); + setNewTodoText(""); + }; + return ( @@ -144,29 +163,10 @@ const List: React.FC = () => { autoCorrect={false} autoComplete="off" numberOfLines={1} + onSubmitEditing={addChecklistItem} /> - diff --git a/apps/app/src/app/login.tsx b/apps/app/src/app/login.tsx index a34f29a..103e62e 100644 --- a/apps/app/src/app/login.tsx +++ b/apps/app/src/app/login.tsx @@ -47,9 +47,11 @@ const Login = () => { {error && ( - - {/* TODO proper styling */} - Error + + + {/* TODO proper styling */} + Error + Failed to log in )} diff --git a/apps/app/src/app/register.tsx b/apps/app/src/app/register.tsx index aae5256..aa81977 100644 --- a/apps/app/src/app/register.tsx +++ b/apps/app/src/app/register.tsx @@ -46,9 +46,11 @@ const Register = () => { /> {error && ( - - {/* TODO proper styling */} - Error + + + {/* TODO proper styling */} + Error + Failed to sign up )} diff --git a/apps/app/src/components/authForm.tsx b/apps/app/src/components/authForm.tsx index d33d9b3..480d5fa 100644 --- a/apps/app/src/components/authForm.tsx +++ b/apps/app/src/components/authForm.tsx @@ -32,6 +32,9 @@ export const AuthForm = ({ onSubmit, isPending, children }: Props) => { onChangeText={(value) => { setUsername(value); }} + onSubmitEditing={() => { + onSubmit({ username, password }); + }} /> { onChangeText={(value) => { setPassword(value); }} + onSubmitEditing={() => { + onSubmit({ username, password }); + }} /> + if (!documentKeyBase64) { + return ( + + Loading document key … + ); + } + + const documentKey = sodium.from_base64(documentKeyBase64); - {checklist.map((entry, index) => { - return ( - - { - const yEntry = yDocument.get(entry.id); - if (yEntry) { - yEntry.set("checked", !entry.checked); - } - }} - /> - - { - const yEntry = yDocument.get(entry.id); - if (yEntry) { - yEntry.set("text", newText); - } - }} - /> - - - - ); - })} - - ); + return ; }; export default List; diff --git a/apps/app/src/components/document.tsx b/apps/app/src/components/document.tsx new file mode 100644 index 0000000..ba651fd --- /dev/null +++ b/apps/app/src/components/document.tsx @@ -0,0 +1,217 @@ +import React, { useEffect, useRef, useState } from "react"; +import { View } from "react-native"; +import sodium, { KeyPair } from "react-native-libsodium"; +import { generateId } from "secsync"; +import { useYjsSync } from "secsync-react-yjs"; +import * as Yjs from "yjs"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Text } from "~/components/ui/text"; +import { X } from "~/lib/icons/X"; +import { Checkbox } from "../components/checkbox"; +import { DocumentMembers } from "../components/documentMember"; +import { SubtleInput } from "../components/subtleInput"; +import { UpdateDocumentNameForm } from "../components/updateDocumentNameForm"; +import { useYData } from "../hooks/useYData"; +import { convertChecklistToArrayAndSort } from "../utils/convertChecklistToArrayAndSort"; +import { deserialize } from "../utils/deserialize"; +import { getDocumentStorage } from "../utils/documentStorage"; +import { position } from "../utils/position"; +import { serialize } from "../utils/serialize"; +import { trpc } from "../utils/trpc"; + +const websocketEndpoint = + process.env.NODE_ENV === "development" + ? "ws://localhost:3030" + : "wss://secsync.fly.dev"; + +type Props = { + documentId: string; + documentKey: Uint8Array; +}; + +const Document: React.FC = ({ documentKey, documentId }) => { + const getDocumentQuery = trpc.getDocument.useQuery(documentId, { + refetchInterval: 5000, + }); + + const [authorKeyPair] = useState(() => { + return sodium.crypto_sign_keypair(); + }); + + // load initial data + const [initialData] = useState(() => { + const yDoc = new Yjs.Doc(); + // load full document + const serializedDoc = + getDocumentStorage().documentStorage.getString(documentId); + if (serializedDoc) { + Yjs.applyUpdateV2(yDoc, deserialize(serializedDoc)); + } + + // loads the pendingChanges + const pendingChanges = + getDocumentStorage().documentPendingChangesStorage.getString(documentId); + + return { + yDoc, + pendingChanges: pendingChanges ? deserialize(pendingChanges) : [], + }; + }); + + const yDocRef = useRef(initialData.yDoc); + + // update the document after every change (could be debounced) + useEffect(() => { + const onUpdate = (update: any) => { + const fullYDoc = Yjs.encodeStateAsUpdateV2(yDocRef.current); + getDocumentStorage().documentStorage.set(documentId, serialize(fullYDoc)); + }; + yDocRef.current.on("updateV2", onUpdate); + + return () => { + yDocRef.current.off("updateV2", onUpdate); + }; + }, []); + + const yDocument: Yjs.Map> = yDocRef.current.getMap("document"); + const document = useYData<{ [k: string]: ChecklistItem }>(yDocument); + const checklist = document ? convertChecklistToArrayAndSort(document) : []; + const [newTodoText, setNewTodoText] = useState(""); + + const [state, send] = useYjsSync({ + yDoc: yDocRef.current, + pendingChanges: initialData.pendingChanges, + // callback to store the pending changes in + onPendingChangesUpdated: (allChanges) => { + getDocumentStorage().documentPendingChangesStorage.set( + documentId, + serialize(allChanges) + ); + }, + documentId, + signatureKeyPair: authorKeyPair, + websocketEndpoint, + websocketSessionKey: "your-secret-session-key", + getNewSnapshotData: async ({ id }) => { + return { + data: Yjs.encodeStateAsUpdateV2(yDocRef.current), + key: documentKey, + publicData: {}, + }; + }, + getSnapshotKey: async () => { + return documentKey; + }, + shouldSendSnapshot: ({ snapshotUpdatesCount }) => { + // create a new snapshot if the active snapshot has more than 100 updates + return snapshotUpdatesCount > 100; + }, + isValidClient: async (signingPublicKey: string) => { + return true; + }, + sodium, + logging: "debug", + }); + + const addChecklistItem = (event: any) => { + event.preventDefault(); + // @ts-expect-error sodium is not typed + const id = generateId(sodium); + const text = new Yjs.Text(newTodoText); + const todo = new Yjs.Map(); + const newPosition = + checklist.length > 0 + ? position.createBetween(undefined, checklist[0].position) + : position.createBetween(); + todo.set("type", "checklist-item"); + todo.set("text", text); + todo.set("checked", false); + todo.set("position", newPosition); + + yDocument.set(id, todo); + setNewTodoText(""); + }; + + return ( + + + + + + + + + + + setNewTodoText(value)} + value={newTodoText} + autoCapitalize="none" + autoCorrect={false} + autoComplete="off" + numberOfLines={1} + onSubmitEditing={addChecklistItem} + /> + + + + + {checklist.map((entry, index) => { + return ( + + { + const yEntry = yDocument.get(entry.id); + if (yEntry) { + yEntry.set("checked", !entry.checked); + } + }} + /> + + { + const yEntry = yDocument.get(entry.id); + if (yEntry) { + yEntry.set("text", newText); + } + }} + /> + + + + ); + })} + + ); +}; + +export default Document; diff --git a/apps/app/src/components/drawerContent.tsx b/apps/app/src/components/drawerContent.tsx index 7a6fa0c..32bee9b 100644 --- a/apps/app/src/components/drawerContent.tsx +++ b/apps/app/src/components/drawerContent.tsx @@ -30,7 +30,8 @@ export const DrawerContent: React.FC = () => { const documentsQuery = trpc.documents.useQuery(undefined, { refetchInterval: 5000, }); - let keys = getDocumentStorage().documentNameStorage.getAllKeys(); + const documentNameStorage = getDocumentStorage().documentNameStorage; + let keys = documentNameStorage.getAllKeys(); if (documentsQuery.data) { const remoteDocumentIds = documentsQuery.data.map((doc) => doc.id); // merge remote and local keys and deduplicate them @@ -77,6 +78,11 @@ export const DrawerContent: React.FC = () => { }) : getDocumentStorage().documentNameStorage.getString(docId); + // store the name if it's not already stored + if (!documentNameStorage.getString(docId)) { + documentNameStorage.set(docId, name || "Untitled"); + } + return ( diff --git a/apps/app/src/hooks/useLocker.ts b/apps/app/src/hooks/useLocker.ts index 3749703..94bbe9b 100644 --- a/apps/app/src/hooks/useLocker.ts +++ b/apps/app/src/hooks/useLocker.ts @@ -27,16 +27,31 @@ export const useLocker = () => { commitment: string; clock: number; }) => { - const localLocker = getCompleteLocalLocker(); + const localLocker = getLockerStorage(); + const localLockerValues = getCompleteLocalLocker(); + const lockerKeyString = getLockerKey(); if (!lockerKeyString) { throw new Error("Locker key not found."); } const lockerKey = sodium.from_base64(lockerKeyString); const contentString = decryptLocker(data, lockerKey); + // TODO validate schema + const newContent = JSON.parse(contentString); + + // update the local locker with the remote locker values + Object.keys(newContent).forEach((key) => { + const value = newContent[key]; + if (!value) { + return; + } + if (!localLockerValues[key]) { + localLocker.set(key, value); + } + }); + return { - // TODO validate schema - ...JSON.parse(contentString), + ...newContent, ...localLocker, }; };