diff --git a/Cargo.lock b/Cargo.lock index 5250160d4..c6e2f875b 100644 Binary files a/Cargo.lock and b/Cargo.lock differ diff --git a/Cargo.toml b/Cargo.toml index b87c2743a..c4d7fe9d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,9 +25,13 @@ prisma-client-rust-cli = { git = "https://github.com/Brendonovich/prisma-client- "sqlite", ], default-features = false } rspc = { version = "0.1.2" } +normi = { version = "0.0.1" } +specta = { version = "0.0.4" } [patch.crates-io] # We use this patch so we can compile for the IOS simulator on M1 openssl-sys = { git = "https://github.com/spacedriveapp/rust-openssl", rev = "92c3dec225a9e984884d5b30a517e5d44a24d03b" } -rspc = { git = "https://github.com/oscartbeaumont/rspc", rev = "b41e4d7868818119d8e3e4e0319d7dce0e675eb0" } # TODO: Move back to crates.io when new jsonrpc executor is released \ No newline at end of file +rspc = { git = "https://github.com/oscartbeaumont/rspc", rev = "7c0a67c1176a8af33b604c68d8edcbf0d70b8429" } # TODO: Move back to crates.io when new jsonrpc executor is released +normi = { git = "https://github.com/oscartbeaumont/rspc", rev = "7c0a67c1176a8af33b604c68d8edcbf0d70b8429" } # TODO: When normi is released on crates.io +specta = { git = "https://github.com/oscartbeaumont/rspc", rev = "7c0a67c1176a8af33b604c68d8edcbf0d70b8429" } # TODO: When normi is released on crates.io diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a567d20a5..72dc6a031 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -11,12 +11,11 @@ "build": "tauri build" }, "dependencies": { - "@rspc/client": "^0.1.2", - "@rspc/tauri": "^0.1.2", + "@rspc/tauri": "^0.0.0-main-7c0a67c1", + "@rspc/client": "^0.0.0-main-7c0a67c1", "@sd/client": "workspace:*", "@sd/interface": "workspace:*", "@sd/ui": "workspace:*", - "@tanstack/react-query": "^4.10.1", "@tauri-apps/api": "1.1.0", "react": "^18.2.0", "react-dom": "^18.2.0" @@ -25,19 +24,14 @@ "@tauri-apps/cli": "1.1.1", "@tauri-apps/tauricon": "github:tauri-apps/tauricon", "@types/babel-core": "^6.25.7", - "@types/byte-size": "^8.1.0", "@types/react": "^18.0.21", "@types/react-dom": "^18.0.6", - "@types/react-router-dom": "^5.3.3", - "@types/react-window": "^1.8.5", "@types/tailwindcss": "^3.1.0", "@vitejs/plugin-react": "^2.1.0", - "concurrently": "^7.4.0", "prettier": "^2.7.1", "sass": "^1.55.0", "typescript": "^4.8.4", "vite": "^3.1.4", - "vite-plugin-filter-replace": "^0.1.9", "vite-plugin-svgr": "^2.2.1" } } diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 582a253a1..7592122aa 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -6,7 +6,7 @@ "build": { "distDir": "../dist", "devPath": "http://localhost:8001", - "beforeDevCommand": "pnpm exec vite --clearScreen=false", + "beforeDevCommand": "pnpm exec vite --clearScreen=false --mode development", "beforeBuildCommand": "pnpm exec vite build" }, "tauri": { diff --git a/apps/desktop/src-tauri/tauri.linux.conf.json b/apps/desktop/src-tauri/tauri.linux.conf.json index f7ebc35cd..31b8f8f01 100644 --- a/apps/desktop/src-tauri/tauri.linux.conf.json +++ b/apps/desktop/src-tauri/tauri.linux.conf.json @@ -6,7 +6,7 @@ "build": { "distDir": "../dist", "devPath": "http://localhost:8001", - "beforeDevCommand": "pnpm exec vite --clearScreen=false", + "beforeDevCommand": "pnpm exec vite --clearScreen=false --mode development", "beforeBuildCommand": "pnpm exec vite build" }, "tauri": { diff --git a/apps/desktop/src/index.tsx b/apps/desktop/src/index.tsx index 5bf37ecfb..60599f534 100644 --- a/apps/desktop/src/index.tsx +++ b/apps/desktop/src/index.tsx @@ -1,6 +1,6 @@ -import { createClient } from '@rspc/client'; -import { TauriTransport } from '@rspc/tauri'; -import { OperatingSystem, PlatformProvider, Procedures, queryClient, rspc } from '@sd/client'; +import { loggerLink } from '@rspc/client'; +import { tauriLink } from '@rspc/tauri'; +import { OperatingSystem, PlatformProvider, hooks, queryClient } from '@sd/client'; import SpacedriveInterface, { Platform } from '@sd/interface'; import { KeybindEvent } from '@sd/interface'; import { dialog, invoke, os, shell } from '@tauri-apps/api'; @@ -10,8 +10,9 @@ import { createRoot } from 'react-dom/client'; import '@sd/ui/style'; -const client = createClient({ - transport: new TauriTransport() +const isDev = import.meta.env.DEV; +const client = hooks.createClient({ + links: [...(isDev ? [loggerLink()] : []), tauriLink()] }); async function getOs(): Promise { @@ -52,11 +53,11 @@ function App() { }, []); return ( - + - + ); } diff --git a/apps/landing/package.json b/apps/landing/package.json index c0dda0abd..f485636b4 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -18,13 +18,6 @@ "@sd/interface": "workspace:*", "@sd/ui": "workspace:*", "@tryghost/content-api": "^1.11.4", - "@types/compression": "^1.7.2", - "@types/express": "^4.17.14", - "@types/marked": "^4.0.7", - "@types/node": "^18.8.2", - "@types/react": "^18.0.21", - "@types/react-burger-menu": "^2.8.3", - "@types/react-dom": "^18.0.6", "@vitejs/plugin-react": "^2.1.0", "clsx": "^1.2.1", "compression": "^1.7.4", @@ -48,7 +41,7 @@ "vite-plugin-ssr": "^0.4.39" }, "devDependencies": { - "@sd/config": "link:../../packages/config", + "@sd/config": "workspace:*", "@tailwindcss/line-clamp": "^0.4.2", "@tailwindcss/typography": "^0.5.7", "@types/prismjs": "^1.26.0", @@ -59,6 +52,13 @@ "sass": "^1.55.0", "tailwind": "^4.0.0", "vite-plugin-markdown": "^2.1.0", - "vite-plugin-svgr": "^2.2.1" + "vite-plugin-svgr": "^2.2.1", + "@types/compression": "^1.7.2", + "@types/express": "^4.17.14", + "@types/marked": "^4.0.7", + "@types/node": "^18.8.2", + "@types/react": "^18.0.21", + "@types/react-burger-menu": "^2.8.3", + "@types/react-dom": "^18.0.6" } } diff --git a/apps/landing/src/App.tsx b/apps/landing/src/App.tsx index ca83abe21..dcfe8d8db 100644 --- a/apps/landing/src/App.tsx +++ b/apps/landing/src/App.tsx @@ -1,5 +1,4 @@ -import { Button } from '@sd/ui'; -import React from 'react'; +import React, { PropsWithChildren } from 'react'; import { PageContextBuiltIn } from 'vite-plugin-ssr'; import { Footer } from './components/Footer'; @@ -12,10 +11,9 @@ import '@sd/ui/style'; export default function App({ children, pageContext -}: { - children: React.ReactNode; +}: PropsWithChildren<{ pageContext: PageContextBuiltIn; -}) { +}>) { return ( diff --git a/apps/landing/src/components/DocsSidebar.tsx b/apps/landing/src/components/DocsSidebar.tsx index 7fff8ae15..413778469 100644 --- a/apps/landing/src/components/DocsSidebar.tsx +++ b/apps/landing/src/components/DocsSidebar.tsx @@ -1,10 +1,8 @@ -import { CogIcon } from '@heroicons/react/24/outline'; import { Input } from '@sd/ui'; import clsx from 'clsx'; import { MagnifyingGlass } from 'phosphor-react'; -import React, { useCallback } from 'react'; -import { DocCategory, DocsNavigation } from '../pages/docs/api'; +import { DocsNavigation } from '../pages/docs/api'; import config from '../pages/docs/docs'; interface Props { diff --git a/apps/landing/src/components/Footer.tsx b/apps/landing/src/components/Footer.tsx index 8b400dc83..456c2720e 100644 --- a/apps/landing/src/components/Footer.tsx +++ b/apps/landing/src/components/Footer.tsx @@ -7,8 +7,9 @@ import { Twitter } from '@icons-pack/react-simple-icons'; import AppLogo from '@sd/assets/images/logo.png'; +import { PropsWithChildren } from 'react'; -function FooterLink(props: { children: string | JSX.Element; link: string; blank?: boolean }) { +function FooterLink(props: PropsWithChildren<{ link: string; blank?: boolean }>) { return ( ) { useEffect(() => { Prism.highlightAll(); }, []); diff --git a/apps/landing/src/components/NavBar.tsx b/apps/landing/src/components/NavBar.tsx index ad9c7101b..d3d4fec2b 100644 --- a/apps/landing/src/components/NavBar.tsx +++ b/apps/landing/src/components/NavBar.tsx @@ -10,12 +10,12 @@ import AppLogo from '@sd/assets/images/logo.png'; import { Dropdown, DropdownItem } from '@sd/ui'; import clsx from 'clsx'; import { DotsThreeVertical, List } from 'phosphor-react'; -import { useEffect, useState } from 'react'; +import { PropsWithChildren, useEffect, useState } from 'react'; import { positions } from '../pages/careers.page'; import { getWindow } from '../utils'; -function NavLink(props: { link?: string; children: string }) { +function NavLink(props: PropsWithChildren<{ link?: string }>) { return ( ) { return ( (undefined as any); function PageContextProvider({ pageContext, children -}: { +}: PropsWithChildren<{ pageContext: PageContextBuiltIn; - children: ReactNode; -}) { +}>) { return {children}; } diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle index 2b07ca96a..1433bd53c 100644 --- a/apps/mobile/android/app/build.gradle +++ b/apps/mobile/android/app/build.gradle @@ -8,9 +8,11 @@ apply plugin: 'org.mozilla.rust-android-gradle.rust-android' cargo { module = "../../rust" libname = "sd_core_mobile" - // profile = 'release', pythonCommand = 'python3' + profile = 'release' targets = ["arm", "arm64", "x86", "x86_64"] + // profile = 'debug' + // targets = ["arm64"] targetDirectory = "../.././../../target" // Monorepo moment } diff --git a/apps/mobile/android/build.gradle b/apps/mobile/android/build.gradle index ab32c2ff7..dc4382afd 100644 --- a/apps/mobile/android/build.gradle +++ b/apps/mobile/android/build.gradle @@ -8,6 +8,7 @@ buildscript { minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '21') compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '31') targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '31') + reactNativeVersion = "0.69.4" // https://github.com/expo/expo/issues/18129 if (findProperty('android.kotlinVersion')) { kotlinVersion = findProperty('android.kotlinVersion') } diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 629507b7c..900825715 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -2,6 +2,7 @@ "expo": { "name": "Spacedrive", "slug": "spacedrive", + "owner": "spacedrive", "version": "0.0.1", "orientation": "portrait", "jsEngine": "hermes", @@ -19,6 +20,11 @@ "android": { "package": "com.spacedrive.app" }, - "privacy": "hidden" + "privacy": "hidden", + "extra": { + "eas": { + "projectId": "0cbf4456-87fb-499c-8dfa-554bfa5129f3" + } + } } } diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json new file mode 100644 index 000000000..05122d5ba --- /dev/null +++ b/apps/mobile/eas.json @@ -0,0 +1,23 @@ +{ + "cli": { + "version": ">= 0.56.0" + }, + "build": { + "development": { + "distribution": "internal", + "android": { + "gradleCommand": ":app:assembleDebug" + }, + "ios": { + "buildConfiguration": "Debug" + } + }, + "preview": { + "distribution": "internal" + }, + "production": {} + }, + "submit": { + "production": {} + } +} diff --git a/apps/mobile/ios/Spacedrive.xcodeproj/project.pbxproj b/apps/mobile/ios/Spacedrive.xcodeproj/project.pbxproj index ecaea1a36..6db8ad8ec 100644 --- a/apps/mobile/ios/Spacedrive.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/Spacedrive.xcodeproj/project.pbxproj @@ -161,7 +161,7 @@ dependencies = ( ); name = Spacedrive; - productName = mobilenew; + productName = Spacedrive; productReference = 13B07F961A680F5B00A75B9A /* Spacedrive.app */; productType = "com.apple.product-type.application"; }; diff --git a/apps/mobile/ios/Spacedrive.xcodeproj/xcshareddata/xcschemes/Spacedrive.xcscheme b/apps/mobile/ios/Spacedrive.xcodeproj/xcshareddata/xcschemes/Spacedrive.xcscheme index 05655c640..5758d3c28 100644 --- a/apps/mobile/ios/Spacedrive.xcodeproj/xcshareddata/xcschemes/Spacedrive.xcscheme +++ b/apps/mobile/ios/Spacedrive.xcodeproj/xcshareddata/xcschemes/Spacedrive.xcscheme @@ -15,9 +15,9 @@ + BuildableName = "Spacedrive.app" + BlueprintName = "Spacedrive" + ReferencedContainer = "container:Spacedrive.xcodeproj"> @@ -35,7 +35,7 @@ BlueprintIdentifier = "00E356ED1AD99517003FC87E" BuildableName = "mobilenewTests.xctest" BlueprintName = "mobilenewTests" - ReferencedContainer = "container:mobilenew.xcodeproj"> + ReferencedContainer = "container:Spacedrive.xcodeproj"> @@ -64,9 +64,9 @@ + BuildableName = "Spacedrive.app" + BlueprintName = "Spacedrive" + ReferencedContainer = "container:Spacedrive.xcodeproj"> @@ -81,9 +81,9 @@ + BuildableName = "Spacedrive.app" + BlueprintName = "Spacedrive" + ReferencedContainer = "container:Spacedrive.xcodeproj"> diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 40f672533..212eb8980 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -3,11 +3,13 @@ "version": "1.0.0", "main": "index.js", "license": "GPL-3.0-only", + "private": true, "scripts": { "start": "expo start --dev-client", "android": "expo run:android", "ios": "expo run:ios", "xcode": "open ios/spacedrive.xcworkspace", + "android-studio": "open -a '/Applications/Android Studio.app' ./android", "lint": "eslint src/**/*.{ts,tsx} && tsc --noEmit", "postinstall": "node scripts/postinstall.js" }, @@ -19,11 +21,11 @@ "@react-navigation/drawer": "^6.4.4", "@react-navigation/native": "^6.0.12", "@react-navigation/stack": "^6.2.3", - "@rspc/client": "^0.1.2", - "@rspc/react": "^0.1.2", + "@rspc/client": "^0.0.0-main-7c0a67c1", + "@rspc/react": "^0.0.0-main-7c0a67c1", "@sd/assets": "workspace:*", "@sd/client": "workspace:*", - "@tanstack/react-query": "^4.2.3", + "@tanstack/react-query": "^4.12.0", "byte-size": "^8.1.0", "class-variance-authority": "^0.2.3", "date-fns": "^2.29.2", @@ -31,7 +33,6 @@ "expo-linking": "~3.2.2", "expo-splash-screen": "~0.16.2", "expo-status-bar": "~1.4.0", - "immer": "^9.0.15", "intl": "^1.2.5", "lottie-react-native": "^5.1.4", "moti": "^0.18.0", @@ -67,6 +68,5 @@ "metro-minify-terser": "^0.72.1", "react-native-svg-transformer": "^1.0.0", "typescript": "^4.7.4" - }, - "private": true + } } diff --git a/apps/mobile/rust/Cargo.toml b/apps/mobile/rust/Cargo.toml index 06eefe59e..d83af8f06 100644 --- a/apps/mobile/rust/Cargo.toml +++ b/apps/mobile/rust/Cargo.toml @@ -22,6 +22,8 @@ openssl = { version = "0.10.42", features = [ openssl-sys = { version = "0.9.76", features = [ "vendored", ] } # Override features of transitive dependencies to support IOS Simulator on M1 +futures = "0.3.24" +tracing = "0.1.37" [target.'cfg(target_os = "ios")'.dependencies] objc = "0.2.7" @@ -31,3 +33,6 @@ objc-foundation = "0.1.1" # This is `not(ios)` instead of `android` because of https://github.com/mozilla/rust-android-gradle/issues/93 [target.'cfg(not(target_os = "ios"))'.dependencies] jni = "0.19.0" + +[target.'cfg(not(target_os = "ios"))'.features] +default = ["sd-core/android"] \ No newline at end of file diff --git a/apps/mobile/rust/src/android.rs b/apps/mobile/rust/src/android.rs index ed93a44de..2f45b7b6b 100644 --- a/apps/mobile/rust/src/android.rs +++ b/apps/mobile/rust/src/android.rs @@ -1,27 +1,14 @@ use std::panic; use crate::{EVENT_SENDER, NODE, RUNTIME, SUBSCRIPTIONS}; -use jni::objects::{GlobalRef, JClass, JObject, JString}; -use jni::{JNIEnv, JavaVM}; +use futures::future::join_all; +use jni::objects::{JClass, JObject, JString}; +use jni::JNIEnv; use rspc::internal::jsonrpc::{handle_json_rpc, Request, Sender, SubscriptionMap}; use sd_core::Node; +use serde_json::Value; use tokio::sync::mpsc::unbounded_channel; - -// fn print(jvm: &JavaVM, class: &GlobalRef, msg: &str) { -// let env = jvm.attach_current_thread().unwrap(); -// env.call_method( -// class, -// "print", -// "(Ljava/lang/String;)V", -// &[env -// .new_string(msg) -// .expect("Couldn't create java string!") -// .into()], -// ) -// .unwrap() -// .l() -// .unwrap(); -// } +use tracing::{error, info}; #[no_mangle] pub extern "system" fn Java_com_spacedrive_app_SDCore_registerCoreEventListener( @@ -86,63 +73,105 @@ pub extern "system" fn Java_com_spacedrive_app_SDCore_handleCoreMsg( let callback = env.new_global_ref(callback).unwrap(); RUNTIME.spawn(async move { - let request: Request = serde_json::from_str(&query).unwrap(); + let (node, router) = { + let node = &mut *NODE.lock().await; + match node { + Some(node) => node.clone(), + None => { + let data_dir: String = { + let env = jvm.attach_current_thread().unwrap(); + let data_dir = env + .call_method( + &class, + "getDataDirectory", + "()Ljava/lang/String;", + &[], + ) + .unwrap() + .l() + .unwrap(); - let node = &mut *NODE.lock().await; - let (node, router) = match node { - Some(node) => node.clone(), - None => { - let data_dir: String = { - let env = jvm.attach_current_thread().unwrap(); - let data_dir = env - .call_method(&class, "getDataDirectory", "()Ljava/lang/String;", &[]) - .unwrap() - .l() - .unwrap(); + env.get_string(data_dir.into()).unwrap().into() + }; - env.get_string(data_dir.into()).unwrap().into() - }; + let new_node = Node::new(data_dir).await; + let new_node = match new_node { + Ok(new_node) => new_node, + Err(err) => { + info!("677 {:?}", err); - let new_node = Node::new(data_dir).await.unwrap(); - node.replace(new_node.clone()); - new_node + // TODO: Android return? + return; + } + }; + + node.replace(new_node.clone()); + new_node + } } }; - let mut channel = EVENT_SENDER.get().unwrap().clone(); - let mut resp = Sender::ResponseAndChannel(None, &mut channel); - handle_json_rpc( - node.get_request_context(), - request, - &router, - &mut resp, - &mut SubscriptionMap::Mutex(&SUBSCRIPTIONS), - ) + let reqs = + match serde_json::from_str::(&query).and_then(|v| match v.is_array() { + true => serde_json::from_value::>(v), + false => serde_json::from_value::(v).map(|v| vec![v]), + }) { + Ok(v) => v, + Err(err) => { + error!("failed to decode JSON-RPC request: {}", err); // Don't use tracing here because it's before the `Node` is initialised which sets that config! + return; + } + }; + + let resps = join_all(reqs.into_iter().map(|request| { + let node = node.clone(); + let router = router.clone(); + async move { + let mut channel = EVENT_SENDER.get().unwrap().clone(); + let mut resp = Sender::ResponseAndChannel(None, &mut channel); + + handle_json_rpc( + node.get_request_context(), + request, + &router, + &mut resp, + &mut SubscriptionMap::Mutex(&SUBSCRIPTIONS), + ) + .await; + + match resp { + Sender::ResponseAndChannel(resp, _) => resp, + _ => unreachable!(), + } + } + })) .await; - match resp { - Sender::Response(Some(resp)) => { - let env = jvm.attach_current_thread().unwrap(); - env.call_method( - &callback, - "resolve", - "(Ljava/lang/Object;)V", - &[env - .new_string(serde_json::to_string(&resp).unwrap()) - .expect("Couldn't create java string!") - .into()], + let env = jvm.attach_current_thread().unwrap(); + env.call_method( + &callback, + "resolve", + "(Ljava/lang/Object;)V", + &[env + .new_string( + serde_json::to_string( + &resps.into_iter().filter_map(|v| v).collect::>(), + ) + .unwrap(), ) - .unwrap(); - } - _ => unreachable!(), - } + .expect("Couldn't create java string!") + .into()], + ) + .unwrap(); }); }); if let Err(err) = result { // TODO: Send rspc error or something here so we can show this in the UI. // TODO: Maybe reinitialise the core cause it could be in an invalid state? - println!( + + // TODO: This log statement doesn't work. I recon the JNI env is being dropped before it's called. + error!( "Error in Java_com_spacedrive_app_SDCore_registerCoreEventListener: {:?}", err ); diff --git a/apps/mobile/rust/src/ios.rs b/apps/mobile/rust/src/ios.rs index a21ead310..d17df064e 100644 --- a/apps/mobile/rust/src/ios.rs +++ b/apps/mobile/rust/src/ios.rs @@ -1,9 +1,11 @@ use crate::{EVENT_SENDER, NODE, RUNTIME, SUBSCRIPTIONS}; +use futures::future::join_all; use objc::{msg_send, runtime::Object, sel, sel_impl}; use objc_foundation::{INSString, NSString}; use objc_id::Id; use rspc::internal::jsonrpc::{handle_json_rpc, Request, Sender, SubscriptionMap}; use sd_core::Node; +use serde_json::Value; use std::{ ffi::{CStr, CString}, os::raw::{c_char, c_void}, @@ -69,39 +71,63 @@ pub unsafe extern "C" fn sd_core_msg(query: *const c_char, resolve: *const c_voi let resolve = RNPromise(resolve); RUNTIME.spawn(async move { - let request: Request = serde_json::from_str(&query).unwrap(); + let reqs = + match serde_json::from_str::(&query).and_then(|v| match v.is_array() { + true => serde_json::from_value::>(v), + false => serde_json::from_value::(v).map(|v| vec![v]), + }) { + Ok(v) => v, + Err(err) => { + println!("failed to decode JSON-RPC request: {}", err); // Don't use tracing here because it's before the `Node` is initialised which sets that config! - let node = &mut *NODE.lock().await; - let (node, router) = match node { - Some(node) => node.clone(), - None => { - let doc_dir = CStr::from_ptr(get_data_directory()) - .to_str() - .unwrap() - .to_string(); - let new_node = Node::new(doc_dir).await.unwrap(); - node.replace(new_node.clone()); - new_node + resolve.resolve( + CString::new(serde_json::to_vec(&(vec![] as Vec)).unwrap()) + .unwrap(), + ); // TODO: Proper error handling + return; + } + }; + + let resps = join_all(reqs.into_iter().map(|request| async move { + let node = &mut *NODE.lock().await; + let (node, router) = match node { + Some(node) => node.clone(), + None => { + let data_dir = CStr::from_ptr(get_data_directory()) + .to_str() + .unwrap() + .to_string(); + let new_node = Node::new(data_dir).await.unwrap(); + node.replace(new_node.clone()); + new_node + } + }; + + let mut channel = EVENT_SENDER.get().unwrap().clone(); + let mut resp = Sender::ResponseAndChannel(None, &mut channel); + handle_json_rpc( + node.get_request_context(), + request, + &router, + &mut resp, + &mut SubscriptionMap::Mutex(&SUBSCRIPTIONS), + ) + .await; + + match resp { + Sender::ResponseAndChannel(resp, _) => resp, + _ => unreachable!(), } - }; - - let mut channel = EVENT_SENDER.get().unwrap().clone(); - let mut resp = Sender::ResponseAndChannel(None, &mut channel); - handle_json_rpc( - node.get_request_context(), - request, - &router, - &mut resp, - &mut SubscriptionMap::Mutex(&SUBSCRIPTIONS), - ) + })) .await; - match resp { - Sender::ResponseAndChannel(Some(resp), _) => { - resolve.resolve(CString::new(serde_json::to_vec(&resp).unwrap()).unwrap()); - } - _ => unreachable!(), - } + resolve.resolve( + CString::new( + serde_json::to_vec(&resps.into_iter().filter_map(|v| v).collect::>()) + .unwrap(), + ) + .unwrap(), + ); }); }); diff --git a/apps/mobile/scripts/postinstall.js b/apps/mobile/scripts/postinstall.js index 8ad068a31..5965af9ea 100644 --- a/apps/mobile/scripts/postinstall.js +++ b/apps/mobile/scripts/postinstall.js @@ -3,11 +3,16 @@ let fs = require('fs-extra'); let path = require('path'); async function copyReactNativeCodegen() { - const sourcePath = path.join(__dirname, '../../../node_modules/react-native-codegen'); - const destPath = path.join(__dirname, '../node_modules/react-native-codegen'); + const paths = [ + ['../../../node_modules/react-native-codegen', '../node_modules/react-native-codegen'], + ['../../../node_modules/jsc-android', '../node_modules/jsc-android'] + ]; - await fs.remove(destPath).catch(() => {}); - await fs.move(sourcePath, destPath); + for (const pathTuple of paths) { + const [src, dest] = [path.join(__dirname, pathTuple[0]), path.join(__dirname, pathTuple[1])]; + await fs.remove(dest).catch(() => {}); + await fs.move(src, dest).catch(() => {}); + } } copyReactNativeCodegen(); diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 472aee36b..b4442a52b 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -1,7 +1,7 @@ import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; import { DefaultTheme, NavigationContainer, Theme } from '@react-navigation/native'; import { createClient } from '@rspc/client'; -import * as sdclient from '@sd/client'; +import { queryClient, rspc, useBridgeQuery, useInvalidateQuery } from '@sd/client'; import { StatusBar } from 'expo-status-bar'; import { useEffect } from 'react'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; @@ -10,14 +10,8 @@ import { useDeviceContext } from 'twrnc'; import { useSnapshot } from 'valtio'; import { GlobalModals } from './components/modals/GlobalModals'; -import { - ReactNativeTransport, - queryClient, - rspc, - useBridgeQuery, - useInvalidateQuery -} from './hooks/rspc'; import useCachedResources from './hooks/useCachedResources'; +import { reactNativeLink } from './lib/rspcReactNativeTransport'; import tw from './lib/tailwind'; import RootNavigator from './navigation'; import OnboardingNavigator from './navigation/OnboardingNavigator'; @@ -26,7 +20,7 @@ import { onboardingStore } from './stores/onboardingStore'; import type { Procedures } from './types/bindings'; const client = createClient({ - transport: new ReactNativeTransport() + links: [reactNativeLink()] }); const NavigatorTheme: Theme = { diff --git a/apps/mobile/src/components/animation/layout.tsx b/apps/mobile/src/components/animation/layout.tsx index f1dabe3ca..bbf735643 100644 --- a/apps/mobile/src/components/animation/layout.tsx +++ b/apps/mobile/src/components/animation/layout.tsx @@ -1,18 +1,18 @@ import { MotiView, useDynamicAnimation } from 'moti'; -import React from 'react'; +import { PropsWithChildren, ReactNode } from 'react'; import { StyleSheet, View } from 'react-native'; import { useDerivedValue, useSharedValue } from 'react-native-reanimated'; import Layout from '~/constants/Layout'; import tw from '~/lib/tailwind'; // Anything wrapped with FadeIn will fade in on mount. -export const FadeInAnimation = ({ children, delay }: { children: any; delay?: number }) => ( +export const FadeInAnimation = ({ children, delay }: PropsWithChildren<{ delay?: number }>) => ( {children} ); -export const FadeInUpAnimation = ({ children, delay }: { children: any; delay?: number }) => ( +export const FadeInUpAnimation = ({ children, delay }: PropsWithChildren<{ delay?: number }>) => ( ); -export const LogoAnimation = ({ children }: { children: any }) => ( +export const LogoAnimation = ({ children }: PropsWithChildren) => ( ( ); type AnimatedHeightProps = { - children?: React.ReactNode; + children?: ReactNode; /** * If `true`, the height will automatically animate to 0. Default: `false`. */ diff --git a/apps/mobile/src/components/browse/BrowseLocationItem.tsx b/apps/mobile/src/components/browse/BrowseLocationItem.tsx index 49c271b1d..7af42524e 100644 --- a/apps/mobile/src/components/browse/BrowseLocationItem.tsx +++ b/apps/mobile/src/components/browse/BrowseLocationItem.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import { FC } from 'react'; import { Pressable, Text, View } from 'react-native'; import tw from '~/lib/tailwind'; @@ -9,7 +9,7 @@ interface BrowseLocationItemProps { onPress: () => void; } -const BrowseLocationItem: React.FC = (props) => { +const BrowseLocationItem: FC = (props) => { const { folderName, onPress } = props; return ( diff --git a/apps/mobile/src/components/browse/BrowseTagItem.tsx b/apps/mobile/src/components/browse/BrowseTagItem.tsx index e89477f1e..ddd77a0ba 100644 --- a/apps/mobile/src/components/browse/BrowseTagItem.tsx +++ b/apps/mobile/src/components/browse/BrowseTagItem.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import { FC } from 'react'; import { ColorValue, Pressable, Text, View } from 'react-native'; import tw from '~/lib/tailwind'; @@ -8,7 +8,7 @@ type BrowseTagItemProps = { onPress: () => void; }; -const BrowseTagItem: React.FC = (props) => { +const BrowseTagItem: FC = (props) => { const { tagName, tagColor, onPress } = props; return ( diff --git a/apps/mobile/src/components/device/Device.tsx b/apps/mobile/src/components/device/Device.tsx index 9c45e7d59..9cfffb9f5 100644 --- a/apps/mobile/src/components/device/Device.tsx +++ b/apps/mobile/src/components/device/Device.tsx @@ -1,5 +1,4 @@ import { Cloud, Desktop, DeviceMobileCamera, Laptop } from 'phosphor-react-native'; -import React from 'react'; import { FlatList, Text, View } from 'react-native'; import { LockClosedIcon } from 'react-native-heroicons/solid'; import tw from '~/lib/tailwind'; diff --git a/apps/mobile/src/components/drawer/DrawerContent.tsx b/apps/mobile/src/components/drawer/DrawerContent.tsx index 49c0b59c8..22af62b30 100644 --- a/apps/mobile/src/components/drawer/DrawerContent.tsx +++ b/apps/mobile/src/components/drawer/DrawerContent.tsx @@ -1,7 +1,6 @@ import { DrawerContentScrollView } from '@react-navigation/drawer'; import { DrawerContentComponentProps } from '@react-navigation/drawer/lib/typescript/src/types'; import { getFocusedRouteNameFromRoute } from '@react-navigation/native'; -import React from 'react'; import { ColorValue, Image, Platform, Pressable, Text, View } from 'react-native'; import { CogIcon } from 'react-native-heroicons/solid'; import Layout from '~/constants/Layout'; diff --git a/apps/mobile/src/components/drawer/DrawerLibraryManager.tsx b/apps/mobile/src/components/drawer/DrawerLibraryManager.tsx index 97ac809f0..28d8fec4e 100644 --- a/apps/mobile/src/components/drawer/DrawerLibraryManager.tsx +++ b/apps/mobile/src/components/drawer/DrawerLibraryManager.tsx @@ -1,10 +1,10 @@ +import { useBridgeMutation } from '@sd/client'; import { MotiView } from 'moti'; -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Pressable, Text, View } from 'react-native'; import { LockClosedIcon } from 'react-native-heroicons/outline'; import { ChevronRightIcon, CogIcon, PlusIcon } from 'react-native-heroicons/solid'; import { useSnapshot } from 'valtio'; -import { useBridgeMutation } from '~/hooks/rspc'; import tw from '~/lib/tailwind'; import { libraryStore, useCurrentLibrary } from '~/stores/libraryStore'; diff --git a/apps/mobile/src/components/drawer/DrawerLocationItem.tsx b/apps/mobile/src/components/drawer/DrawerLocationItem.tsx index c950f2e82..a82e911ca 100644 --- a/apps/mobile/src/components/drawer/DrawerLocationItem.tsx +++ b/apps/mobile/src/components/drawer/DrawerLocationItem.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import { FC } from 'react'; import { Pressable, Text, View } from 'react-native'; import tw from '~/lib/tailwind'; @@ -9,7 +9,7 @@ interface DrawerLocationItemProps { onPress: () => void; } -const DrawerLocationItem: React.FC = (props) => { +const DrawerLocationItem: FC = (props) => { const { folderName, onPress } = props; return ( diff --git a/apps/mobile/src/components/drawer/DrawerTagItem.tsx b/apps/mobile/src/components/drawer/DrawerTagItem.tsx index d364e9845..5d405ddd4 100644 --- a/apps/mobile/src/components/drawer/DrawerTagItem.tsx +++ b/apps/mobile/src/components/drawer/DrawerTagItem.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import { FC } from 'react'; import { ColorValue, Pressable, Text, View } from 'react-native'; import tw from '~/lib/tailwind'; @@ -8,7 +8,7 @@ type DrawerTagItemProps = { onPress: () => void; }; -const DrawerTagItem: React.FC = (props) => { +const DrawerTagItem: FC = (props) => { const { tagName, tagColor, onPress } = props; return ( diff --git a/apps/mobile/src/components/file/FileIcon.tsx b/apps/mobile/src/components/file/FileIcon.tsx index db646bebe..56b086e75 100644 --- a/apps/mobile/src/components/file/FileIcon.tsx +++ b/apps/mobile/src/components/file/FileIcon.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { Text, View } from 'react-native'; import Svg, { Path } from 'react-native-svg'; diff --git a/apps/mobile/src/components/header/Header.tsx b/apps/mobile/src/components/header/Header.tsx index df139c78f..99f51b770 100644 --- a/apps/mobile/src/components/header/Header.tsx +++ b/apps/mobile/src/components/header/Header.tsx @@ -3,7 +3,6 @@ import { DrawerNavigationHelpers } from '@react-navigation/drawer/lib/typescript import { useNavigation } from '@react-navigation/native'; import { MotiView } from 'moti'; import { List } from 'phosphor-react-native'; -import React from 'react'; import { Pressable, Text, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import tw from '~/lib/tailwind'; diff --git a/apps/mobile/src/components/icons/FolderIcon.tsx b/apps/mobile/src/components/icons/FolderIcon.tsx index 311544248..54f994f9f 100644 --- a/apps/mobile/src/components/icons/FolderIcon.tsx +++ b/apps/mobile/src/components/icons/FolderIcon.tsx @@ -1,6 +1,5 @@ import FolderWhite from '@sd/assets/svgs/folder-white.svg'; import Folder from '@sd/assets/svgs/folder.svg'; -import React from 'react'; import { SvgProps } from 'react-native-svg'; type FolderProps = { diff --git a/apps/mobile/src/components/layout/CollapsibleView.tsx b/apps/mobile/src/components/layout/CollapsibleView.tsx index 0d55838ae..79aab0913 100644 --- a/apps/mobile/src/components/layout/CollapsibleView.tsx +++ b/apps/mobile/src/components/layout/CollapsibleView.tsx @@ -1,17 +1,16 @@ import { MotiView } from 'moti'; -import React, { useReducer } from 'react'; +import { PropsWithChildren, ReactNode, useReducer } from 'react'; import { Pressable, StyleProp, Text, TextStyle, View, ViewStyle } from 'react-native'; import { ChevronRightIcon } from 'react-native-heroicons/solid'; import tw from '~/lib/tailwind'; import { AnimatedHeight } from '../animation/layout'; -type CollapsibleViewProps = { +type CollapsibleViewProps = PropsWithChildren<{ title: string; titleStyle?: StyleProp; - children: React.ReactNode; containerStyle?: StyleProp; -}; +}>; const CollapsibleView = ({ title, titleStyle, containerStyle, children }: CollapsibleViewProps) => { const [hide, toggle] = useReducer((hide) => !hide, false); diff --git a/apps/mobile/src/components/layout/Dialog.tsx b/apps/mobile/src/components/layout/Dialog.tsx index 4b6a634d7..100969fcf 100644 --- a/apps/mobile/src/components/layout/Dialog.tsx +++ b/apps/mobile/src/components/layout/Dialog.tsx @@ -1,5 +1,5 @@ import { MotiView } from 'moti'; -import React, { useState } from 'react'; +import { ReactNode, useState } from 'react'; import { KeyboardAvoidingView, Modal, Platform, Pressable, Text, View } from 'react-native'; import tw from '~/lib/tailwind'; @@ -8,7 +8,7 @@ import { Button } from '../primitive/Button'; type DialogProps = { title: string; description?: string; - trigger?: React.ReactNode; + trigger?: ReactNode; /** * if `true`, dialog will be visible when mounted. * It can be used when trigger is not provided and/or you need to open the dialog programmatically @@ -19,7 +19,7 @@ type DialogProps = { * It can be used to control dialog state from outside */ setIsVisible?: (v: boolean) => void; - children?: React.ReactNode; + children?: ReactNode; ctaAction?: () => void; ctaLabel?: string; ctaDanger?: boolean; diff --git a/apps/mobile/src/components/layout/VirtualizedListWrapper.tsx b/apps/mobile/src/components/layout/VirtualizedListWrapper.tsx index 6a110b358..9f09840bb 100644 --- a/apps/mobile/src/components/layout/VirtualizedListWrapper.tsx +++ b/apps/mobile/src/components/layout/VirtualizedListWrapper.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { FlatList } from 'react-native'; export default function VirtualizedListWrapper({ children }) { diff --git a/apps/mobile/src/components/modals/layout/ModalBackdrop.tsx b/apps/mobile/src/components/modals/layout/ModalBackdrop.tsx index d14677b15..6faba98f6 100644 --- a/apps/mobile/src/components/modals/layout/ModalBackdrop.tsx +++ b/apps/mobile/src/components/modals/layout/ModalBackdrop.tsx @@ -1,5 +1,4 @@ import { BottomSheetBackdrop, BottomSheetBackdropProps } from '@gorhom/bottom-sheet'; -import React from 'react'; const ModalBackdrop = (props: BottomSheetBackdropProps) => { return ( diff --git a/apps/mobile/src/components/modals/layout/ModalHandle.tsx b/apps/mobile/src/components/modals/layout/ModalHandle.tsx index 22efbe7d1..c60f488fa 100644 --- a/apps/mobile/src/components/modals/layout/ModalHandle.tsx +++ b/apps/mobile/src/components/modals/layout/ModalHandle.tsx @@ -1,5 +1,4 @@ import { BottomSheetHandle, BottomSheetHandleProps } from '@gorhom/bottom-sheet'; -import React from 'react'; import tw from '../../../lib/tailwind'; diff --git a/apps/mobile/src/components/primitive/Button.tsx b/apps/mobile/src/components/primitive/Button.tsx index d2970e3d2..382084086 100644 --- a/apps/mobile/src/components/primitive/Button.tsx +++ b/apps/mobile/src/components/primitive/Button.tsx @@ -1,6 +1,6 @@ import { VariantProps, cva } from 'class-variance-authority'; import { MotiPressable, MotiPressableProps } from 'moti/interactions'; -import React, { useMemo } from 'react'; +import { FC, useMemo } from 'react'; import { Pressable, PressableProps } from 'react-native'; import tw from '~/lib/tailwind'; @@ -28,14 +28,14 @@ const button = cva(['border rounded-md items-center shadow-sm'], { type ButtonProps = VariantProps & PressableProps; -export const Button: React.FC = ({ variant, size, ...props }) => { +export const Button: FC = ({ variant, size, ...props }) => { const { style, ...otherProps } = props; return ; }; type AnimatedButtonProps = VariantProps & MotiPressableProps; -export const AnimatedButton: React.FC = ({ variant, size, ...props }) => { +export const AnimatedButton: FC = ({ variant, size, ...props }) => { const { style, containerStyle, ...otherProps } = props; return ( & RNTextInputProps; -export const TextInput: React.FC = ({ variant, ...props }) => { +export const TextInput: FC = ({ variant, ...props }) => { const { style, ...otherProps } = props; return ( = ({ title, bytes }) => { +const StatItem: FC<{ title: string; bytes: number }> = ({ title, bytes }) => { const { value, unit } = byteSize(+bytes); const count = useCounter({ name: title, end: Number(value) }); diff --git a/apps/mobile/src/hooks/rspc.ts b/apps/mobile/src/hooks/rspc.ts deleted file mode 100644 index 774f03b7c..000000000 --- a/apps/mobile/src/hooks/rspc.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { OperationType, ProcedureDef, RSPCError, Transport } from '@rspc/client'; -import { createReactQueryHooks } from '@rspc/react'; -import { QueryClient } from '@tanstack/react-query'; -import { NativeEventEmitter, NativeModules } from 'react-native'; - -import { getLibraryIdRaw } from '../stores/libraryStore'; -import { LibraryArgs, Procedures } from '../types/bindings'; - -export const queryClient = new QueryClient(); -export const rspc = createReactQueryHooks(); - -const { SDCore } = NativeModules; -const eventEmitter = new NativeEventEmitter(NativeModules.SDCore); - -// TODO(@Oscar): Replace this with a better abstraction when it's released in rspc. This relies on internal details of rspc which will change without warning. -export class ReactNativeTransport implements Transport { - clientSubscriptionCallback?: (id: string, value: any) => void; - - constructor() { - const subscriptionEventListener = eventEmitter.addListener('SDCoreEvent', (event) => { - const { id, result } = JSON.parse(event); - if (result.type === 'event') { - if (this.clientSubscriptionCallback) this.clientSubscriptionCallback(id, result.data); - } else if (result.type === 'response' || result.type === 'error') { - throw new Error( - `Recieved event of type '${result.type}'. This should be impossible with the React Native transport!` - ); - } else { - console.error(`Received event of unknown method '${result.type}'`); - } - }); - } - - async doRequest(operation: OperationType, key: string, input: any): Promise { - const resp = JSON.parse( - await SDCore.sd_core_msg( - JSON.stringify({ - id: null, - method: operation, - params: { - path: key, - input - } - }) - ) - ); - - const body = resp.result; - if (body.type === 'error') { - const { code, message } = body; - throw new RSPCError(code, message); - } else if (body.type === 'response') { - return body.data; - } else if (body.type !== 'none') { - throw new Error(`RSPC ReactNative doRequest received invalid body type '${body?.type}'`); - } - } -} - -type NonLibraryProcedure = - | Exclude }> - | Extract; - -type LibraryProcedures = Exclude< - Extract }>, - { input: never } ->; - -type MoreConstrainedQueries = T extends any - ? T['input'] extends LibraryArgs - ? { - key: T['key']; - input: E; - result: T['result']; - } - : never - : never; - -export const useBridgeQuery = rspc.customQuery>( - (keyAndInput) => keyAndInput as any -); - -export const useBridgeMutation = rspc.customMutation>( - (keyAndInput) => keyAndInput -); - -export const useLibraryQuery = rspc.customQuery< - MoreConstrainedQueries> ->((keyAndInput) => { - const library_id = getLibraryIdRaw(); - if (library_id === null) throw new Error('Attempted to do library query with no library set!'); - return [keyAndInput[0], { library_id, arg: keyAndInput[1] || null }]; -}); - -export const useLibraryMutation = rspc.customMutation< - MoreConstrainedQueries> ->((keyAndInput) => { - const library_id = getLibraryIdRaw(); - if (library_id === null) throw new Error('Attempted to do library query with no library set!'); - return [keyAndInput[0], { library_id, arg: keyAndInput[1] || null }]; -}); - -export function useInvalidateQuery() { - const context = rspc.useContext(); - rspc.useSubscription(['invalidateQuery'], { - onData: (invalidateOperation) => { - const key = [invalidateOperation.key]; - if (invalidateOperation.arg !== null) { - key.concat(invalidateOperation.arg); - } - context.queryClient.invalidateQueries(key); - } - }); -} diff --git a/apps/mobile/src/hooks/useCounter.ts b/apps/mobile/src/hooks/useCounter.ts index 5b28a5148..a8547d08d 100644 --- a/apps/mobile/src/hooks/useCounter.ts +++ b/apps/mobile/src/hooks/useCounter.ts @@ -2,18 +2,6 @@ import { useEffect } from 'react'; import { useCountUp } from 'use-count-up'; import { proxy, useSnapshot } from 'valtio'; -// const useCounterStore = create<{ -// counterLastValue: Map; -// setCounterLastValue: (key: string, value: number) => void; -// }>((set) => ({ -// counterLastValue: new Map(), -// setCounterLastValue: (name, lastValue) => -// set((state) => ({ -// ...state, -// counterLastValue: state.counterLastValue.set(name, lastValue) -// })) -// })); - const counterStore = proxy({ counterLastValue: new Map(), setCounterLastValue: (key: string, value: number) => { diff --git a/apps/mobile/src/lib/rspcReactNativeTransport.ts b/apps/mobile/src/lib/rspcReactNativeTransport.ts new file mode 100644 index 000000000..f79f89859 --- /dev/null +++ b/apps/mobile/src/lib/rspcReactNativeTransport.ts @@ -0,0 +1,146 @@ +import { + Operation, + ProcedureType, + ProceduresDef, + TRPCClientOutgoingMessage, + TRPCLink, + TRPCRequestMessage, + TRPCWebSocketClient, + UnsubscribeFn, + wsLink +} from '@rspc/client'; +import { NativeEventEmitter, NativeModules } from 'react-native'; + +type TCallbacks = any; // TODO + +const { SDCore } = NativeModules; +const eventEmitter = new NativeEventEmitter(NativeModules.SDCore); + +export function reactNativeLink(): TRPCLink { + return wsLink({ + client: createReactNativeClient() + }); +} + +export function createReactNativeClient(): TRPCWebSocketClient { + /** + * outgoing messages buffer whilst not open + */ + let outgoing: TRPCClientOutgoingMessage[] = []; + /** + * pending outgoing requests that are awaiting callback + */ + type TRequest = { + /** + * Reference to the WebSocket instance this request was made to + */ + ws: WebSocket; + type: ProcedureType; + callbacks: TCallbacks; + op: Operation; + }; + const pendingRequests: Record = Object.create(null); + let dispatchTimer: ReturnType | number | null = null; + let state: 'open' | 'closed' = 'open'; + + function handleIncoming(data: any) { + if ('method' in data) { + // + } else { + const req = data.id !== null && pendingRequests[data.id]; + if (!req) { + // do something? + return; + } + req.callbacks.next?.(data); + if ('result' in data && data.result.type === 'stopped') { + req.callbacks.complete(); + } + } + } + + function dispatch() { + if (state !== 'open' || dispatchTimer) { + return; + } + dispatchTimer = setTimeout(() => { + dispatchTimer = null; + + if (outgoing.length === 0) { + return; + } + + let body: any; + if (outgoing.length === 1) { + // single send + body = JSON.stringify(outgoing.pop()); + } else { + // batch send + body = JSON.stringify(outgoing); + } + + SDCore.sd_core_msg(body).then((rawData) => { + const data = JSON.parse(rawData); + if (Array.isArray(data)) { + for (const payload of data) { + handleIncoming(payload); + } + } else { + handleIncoming(data); + } + }); + + // clear + outgoing = []; + }); + } + + eventEmitter.addListener('SDCoreEvent', (event) => { + const data = JSON.parse(event); + handleIncoming(data); + }); + + function request(op: Operation, callbacks: TCallbacks): UnsubscribeFn { + const { type, input, path, id } = op; + const envelope: TRPCRequestMessage = { + id, + method: type, + params: { + input, + path + } + }; + pendingRequests[id] = { + ws: undefined as any, // TODO: Remove this field + type, + callbacks, + op + }; + // enqueue message + outgoing.push(envelope); + dispatch(); + return () => { + const callbacks = pendingRequests[id]?.callbacks; + delete pendingRequests[id]; + outgoing = outgoing.filter((msg) => msg.id !== id); + callbacks?.complete?.(); + if (op.type === 'subscription') { + outgoing.push({ + id, + method: 'subscriptionStop' + }); + dispatch(); + } + }; + } + + return { + close: () => { + state = 'closed'; + // TODO: Close all open subscriptions + // closeIfNoPending(activeConnection); + // TODO + }, + request + }; +} diff --git a/apps/mobile/src/screens/Browse.tsx b/apps/mobile/src/screens/Browse.tsx index 3721f0c03..f3bb13188 100644 --- a/apps/mobile/src/screens/Browse.tsx +++ b/apps/mobile/src/screens/Browse.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { ColorValue, Text, View } from 'react-native'; import BrowseLocationItem from '~/components/browse/BrowseLocationItem'; import BrowseTagItem from '~/components/browse/BrowseTagItem'; diff --git a/apps/mobile/src/screens/Location.tsx b/apps/mobile/src/screens/Location.tsx index 2dd2525e7..3e0e5dd0d 100644 --- a/apps/mobile/src/screens/Location.tsx +++ b/apps/mobile/src/screens/Location.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { Text, View } from 'react-native'; import tw from '~/lib/tailwind'; import { SharedScreenProps } from '~/navigation/SharedScreens'; diff --git a/apps/mobile/src/screens/Overview.tsx b/apps/mobile/src/screens/Overview.tsx index 61424a7ca..dc7f5ff06 100644 --- a/apps/mobile/src/screens/Overview.tsx +++ b/apps/mobile/src/screens/Overview.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { FlatList, View } from 'react-native'; import Device from '~/components/device/Device'; import VirtualizedListWrapper from '~/components/layout/VirtualizedListWrapper'; diff --git a/apps/mobile/src/screens/Photos.tsx b/apps/mobile/src/screens/Photos.tsx index c1ee9c4eb..a3cc2f50b 100644 --- a/apps/mobile/src/screens/Photos.tsx +++ b/apps/mobile/src/screens/Photos.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { Text, View } from 'react-native'; import tw from '~/lib/tailwind'; import { PhotosStackScreenProps } from '~/navigation/tabs/PhotosStack'; diff --git a/apps/mobile/src/screens/Spaces.tsx b/apps/mobile/src/screens/Spaces.tsx index 10c8cc5b9..8662d5eb3 100644 --- a/apps/mobile/src/screens/Spaces.tsx +++ b/apps/mobile/src/screens/Spaces.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { Text, View } from 'react-native'; import tw from '~/lib/tailwind'; import { SpacesStackScreenProps } from '~/navigation/tabs/SpacesStack'; diff --git a/apps/mobile/src/screens/Tag.tsx b/apps/mobile/src/screens/Tag.tsx index 6f5278551..0b61f81a8 100644 --- a/apps/mobile/src/screens/Tag.tsx +++ b/apps/mobile/src/screens/Tag.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { Text, View } from 'react-native'; import tw from '~/lib/tailwind'; import { SharedScreenProps } from '~/navigation/SharedScreens'; diff --git a/apps/mobile/src/screens/modals/Search.tsx b/apps/mobile/src/screens/modals/Search.tsx index 3fab3b1f7..82e99fb5a 100644 --- a/apps/mobile/src/screens/modals/Search.tsx +++ b/apps/mobile/src/screens/modals/Search.tsx @@ -1,5 +1,5 @@ import { MagnifyingGlass } from 'phosphor-react-native'; -import React, { useState } from 'react'; +import { useState } from 'react'; import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Button } from '~/components/primitive/Button'; diff --git a/apps/mobile/src/screens/modals/settings/Settings.tsx b/apps/mobile/src/screens/modals/settings/Settings.tsx index d30472da8..c5056b924 100644 --- a/apps/mobile/src/screens/modals/settings/Settings.tsx +++ b/apps/mobile/src/screens/modals/settings/Settings.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { Text, View } from 'react-native'; import tw from '~/lib/tailwind'; import { RootStackScreenProps } from '~/navigation'; diff --git a/apps/mobile/src/screens/onboarding/CreateLibrary.tsx b/apps/mobile/src/screens/onboarding/CreateLibrary.tsx index 87aaafae3..377128fa1 100644 --- a/apps/mobile/src/screens/onboarding/CreateLibrary.tsx +++ b/apps/mobile/src/screens/onboarding/CreateLibrary.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { Text, View } from 'react-native'; import { useSnapshot } from 'valtio'; import { AnimatedButton } from '~/components/primitive/Button'; diff --git a/apps/mobile/src/screens/onboarding/Onboarding.tsx b/apps/mobile/src/screens/onboarding/Onboarding.tsx index ebeed11f8..aa9c864c8 100644 --- a/apps/mobile/src/screens/onboarding/Onboarding.tsx +++ b/apps/mobile/src/screens/onboarding/Onboarding.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { Image, Text, View } from 'react-native'; import { FadeInUpAnimation, LogoAnimation } from '~/components/animation/layout'; import { AnimatedButton } from '~/components/primitive/Button'; diff --git a/apps/mobile/src/stores/libraryStore.ts b/apps/mobile/src/stores/libraryStore.ts index 20f6197c8..f4ae52014 100644 --- a/apps/mobile/src/stores/libraryStore.ts +++ b/apps/mobile/src/stores/libraryStore.ts @@ -1,7 +1,7 @@ +import { useBridgeQuery } from '@sd/client'; import { useMemo } from 'react'; import { useSnapshot } from 'valtio'; import proxyWithPersist, { PersistStrategy } from 'valtio-persist'; -import { useBridgeQuery } from '~/hooks/rspc'; import { LibraryConfigWrapped } from '~/types/bindings'; import { StorageEngine } from './utils'; diff --git a/apps/mobile/src/stores/modalStore.ts b/apps/mobile/src/stores/modalStore.ts index 4753294f5..834f71177 100644 --- a/apps/mobile/src/stores/modalStore.ts +++ b/apps/mobile/src/stores/modalStore.ts @@ -1,11 +1,11 @@ import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types'; -import React from 'react'; +import { RefObject } from 'react'; import { proxy } from 'valtio'; import { FilePath } from '../types/bindings'; export const fileModalStore = proxy({ - fileRef: null as React.RefObject, + fileRef: null as RefObject, data: null as FilePath | null, setData: (data: FilePath) => { fileModalStore.data = data; diff --git a/apps/mobile/src/types/bindings.ts b/apps/mobile/src/types/bindings.ts index f9a387595..db4e1ff94 100644 --- a/apps/mobile/src/types/bindings.ts +++ b/apps/mobile/src/types/bindings.ts @@ -14,6 +14,11 @@ export type Procedures = { { key: "locations.indexer_rules.get", input: LibraryArgs, result: IndexerRule } | { key: "locations.indexer_rules.list", input: LibraryArgs, result: Array } | { key: "locations.list", input: LibraryArgs, result: Array<{ id: number, pub_id: Array, node_id: number, name: string | null, local_path: string | null, total_capacity: number | null, available_capacity: number | null, filesystem: string | null, disk_type: number | null, is_removable: boolean | null, is_online: boolean, is_archived: boolean, date_created: string, node: Node }> } | + { key: "normi.composite", input: never, result: NormalisedCompositeId } | + { key: "normi.org", input: never, result: NormalisedOrganisation } | + { key: "normi.user", input: never, result: NormalisedUser } | + { key: "normi.userSync", input: never, result: NormalisedUser } | + { key: "normi.version", input: never, result: string } | { key: "tags.get", input: LibraryArgs, result: Tag | null } | { key: "tags.getExplorerData", input: LibraryArgs, result: ExplorerData } | { key: "tags.getForObject", input: LibraryArgs, result: Array } | @@ -92,6 +97,14 @@ export interface NodeConfig { version: string | null, id: string, name: string, export interface NodeState { version: string | null, id: string, name: string, p2p_port: number | null, data_path: string } +export interface NormalisedCompositeId { $type: string, $id: any, org_id: string, user_id: string } + +export interface NormalisedOrganisation { $type: string, $id: any, id: string, name: string, users: NormalizedVec, owner: NormalisedUser, non_normalised_data: Array } + +export interface NormalisedUser { $type: string, $id: any, id: string, name: string } + +export interface NormalizedVec { $type: string, edges: Array } + export interface Object { id: number, cas_id: string, integrity_checksum: string | null, name: string | null, extension: string | null, kind: number, size_in_bytes: string, key_id: number | null, hidden: boolean, favorite: boolean, important: boolean, has_thumbnail: boolean, has_thumbstrip: boolean, has_video_preview: boolean, ipfs_id: string | null, note: string | null, date_created: string, date_modified: string, date_indexed: string } export interface ObjectValidatorArgs { id: number, path: string } diff --git a/apps/web/package.json b/apps/web/package.json index 15588492d..13c84b210 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,11 +9,11 @@ }, "dependencies": { "@fontsource/inter": "^4.5.13", - "@rspc/client": "^0.1.2", + "@rspc/client": "^0.0.0-main-7c0a67c1", "@sd/client": "workspace:*", "@sd/interface": "workspace:*", "@sd/ui": "workspace:*", - "@tanstack/react-query": "^4.10.1", + "@tanstack/react-query": "^4.12.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f944d8a85..31518f549 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,12 +1,20 @@ -import { WebsocketTransport, createClient } from '@rspc/client'; -import { PlatformProvider, Procedures, queryClient, rspc } from '@sd/client'; +import { createWSClient, loggerLink, wsLink } from '@rspc/client'; +import { PlatformProvider, hooks, queryClient } from '@sd/client'; import SpacedriveInterface, { Platform } from '@sd/interface'; import { useEffect } from 'react'; -const client = createClient({ - transport: new WebsocketTransport( - import.meta.env.VITE_SDSERVER_BASE_URL || 'ws://localhost:8080/rspc/ws' - ) +const wsClient = createWSClient({ + url: import.meta.env.VITE_SDSERVER_BASE_URL || 'ws://localhost:8080/rspc/ws' +}); + +const isDev = import.meta.env.DEV && false; // TODO: Remove false +const client = hooks.createClient({ + links: [ + ...(isDev ? [loggerLink()] : []), + wsLink({ + client: wsClient + }) + ] }); const platform: Platform = { @@ -21,11 +29,11 @@ function App() { return (
- + - +
); } diff --git a/core/Cargo.toml b/core/Cargo.toml index c0889def6..be783ebfa 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -14,6 +14,7 @@ p2p = [ ] # This feature controls whether the Spacedrive Core contains the Peer to Peer syncing engine (It isn't required for the hosted core so we can disable it). mobile = [ ] # This feature allows features to be disabled when the Core is running on mobile. +android = ["dep:tracing-android"] ffmpeg = [ "dep:ffmpeg-next", "dep:sd-ffmpeg", @@ -36,6 +37,8 @@ blake3 = "1.3.1" # Project dependencies rspc = { workspace = true, features = ["uuid", "chrono", "tracing"] } prisma-client-rust = { workspace = true } +normi = { workspace = true } +specta = { workspace = true } uuid = { version = "1.1.2", features = ["v4", "serde"] } sysinfo = "0.26.4" thiserror = "1.0.37" @@ -51,10 +54,11 @@ image = "0.24.4" webp = "0.2.2" ffmpeg-next = { version = "5.1.1", optional = true, features = [] } sd-ffmpeg = { path = "../crates/ffmpeg", optional = true } -sd-file-ext = { path = "../crates/file-ext"} +sd-file-ext = { path = "../crates/file-ext" } fs_extra = "1.2.0" tracing = "0.1.36" tracing-subscriber = { version = "0.3.15", features = ["env-filter"] } +tracing-android = { version = "0.2.0", optional = true } async-stream = "0.3.3" once_cell = "1.15.0" ctor = "0.1.23" diff --git a/core/src/api/mod.rs b/core/src/api/mod.rs index d24b8e011..b8ec70b8e 100644 --- a/core/src/api/mod.rs +++ b/core/src/api/mod.rs @@ -1,5 +1,4 @@ use std::{ - path::PathBuf, sync::Arc, time::{Duration, Instant}, }; @@ -39,6 +38,7 @@ mod files; mod jobs; mod libraries; mod locations; +mod normi; mod tags; pub mod utils; pub mod volumes; @@ -51,13 +51,15 @@ struct NodeState { } pub(crate) fn mount() -> Arc { + let config = Config::new().set_ts_bindings_header("/* eslint-disable */"); + + #[cfg(all(debug_assertions, not(feature = "mobile")))] + let config = config.export_ts_bindings( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../packages/client/src/core.ts"), + ); + let r = ::new() - .config( - Config::new() - // TODO: This messes with Tauri's hot reload so we can't use it until their is a solution upstream. https://github.com/tauri-apps/tauri/issues/4617 - // .export_ts_bindings(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("./index.ts")), - .set_ts_bindings_header("/* eslint-disable */"), - ) + .config(config) .query("version", |t| t(|_, _: ()| env!("CARGO_PKG_VERSION"))) .query("getNode", |t| { t(|ctx, _: ()| async move { @@ -68,6 +70,7 @@ pub(crate) fn mount() -> Arc { }) }) }) + .merge("normi.", normi::mount()) .merge("library.", libraries::mount()) .merge("volumes.", volumes::mount()) .merge("tags.", tags::mount()) @@ -99,24 +102,14 @@ pub(crate) fn mount() -> Arc { .build() .arced(); InvalidRequests::validate(r.clone()); // This validates all invalidation calls. - export_ts_bindings(&r); r } -pub fn export_ts_bindings(r: &Router) { - r.export_ts(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../packages/client/src/core.ts")) - .expect("Error exporting rspc Typescript bindings!"); - r.export_ts( - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../apps/mobile/src/types/bindings.ts"), - ) - .expect("Error exporting rspc Typescript bindings!"); -} - #[cfg(test)] mod tests { /// This test will ensure the rspc router and all calls to `invalidate_query` are valid and also export an updated version of the Typescript bindings. #[test] fn test_and_export_rspc_bindings() { - super::export_ts_bindings(&super::mount()); + super::mount(); } } diff --git a/core/src/api/normi.rs b/core/src/api/normi.rs new file mode 100644 index 000000000..f345ca285 --- /dev/null +++ b/core/src/api/normi.rs @@ -0,0 +1,91 @@ +use normi::{typed, Object}; +use rspc::Type; +use serde::Serialize; + +use super::RouterBuilder; + +#[derive(Serialize, Type, Object)] +#[normi(rename = "org")] +pub struct Organisation { + #[normi(id)] + pub id: String, + pub name: String, + #[normi(refr)] + pub users: Vec, + #[normi(refr)] + pub owner: User, + pub non_normalised_data: Vec<()>, +} + +#[derive(Serialize, Type, Object)] +pub struct User { + #[normi(id)] + pub id: String, + pub name: String, +} + +#[derive(Serialize, Type, Object)] +pub struct CompositeId { + #[normi(id)] + pub org_id: String, + #[normi(id)] + pub user_id: String, +} + +pub fn mount() -> RouterBuilder { + RouterBuilder::new() + .query("version", |t| t(|_, _: ()| "0.1.0")) + .query("userSync", |t| { + t.resolver(|_, _: ()| User { + id: "1".to_string(), + name: "Monty Beaumont".to_string(), + }) + .map(typed) + }) + .query("user", |t| { + t.resolver(|_, _: ()| async move { + Ok(User { + id: "1".to_string(), + name: "Monty Beaumont".to_string(), + }) + }) + .map(typed) + }) + .query("org", |t| { + t.resolver(|_, _: ()| async move { + Ok(Organisation { + id: "org-1".into(), + name: "Org 1".into(), + users: vec![ + User { + id: "user-1".into(), + name: "Monty Beaumont".into(), + }, + User { + id: "user-2".into(), + name: "Millie Beaumont".into(), + }, + User { + id: "user-3".into(), + name: "Oscar Beaumont".into(), + }, + ], + owner: User { + id: "user-1".into(), + name: "Monty Beaumont".into(), + }, + non_normalised_data: vec![(), ()], + }) + }) + .map(typed) + }) + .query("composite", |t| { + t.resolver(|_, _: ()| async move { + Ok(CompositeId { + org_id: "org-1".into(), + user_id: "user-1".into(), + }) + }) + .map(typed) + }) +} diff --git a/core/src/api/utils/library.rs b/core/src/api/utils/library.rs index 7fb2a6c05..8f537d3e5 100644 --- a/core/src/api/utils/library.rs +++ b/core/src/api/utils/library.rs @@ -85,7 +85,7 @@ where TArg: DeserializeOwned + specta::Type + Send + 'static, { self.query(key, move |t| { - let resolver = Arc::new(builder(UnbuiltProcedureBuilder::new(t.data())).resolver); + let resolver = Arc::new(builder(UnbuiltProcedureBuilder::from_builder(&t)).resolver); t(move |ctx, arg: LibraryArgs| { let resolver = resolver.clone(); @@ -131,7 +131,7 @@ where TArg: DeserializeOwned + specta::Type + Send + 'static, { self.mutation(key, move |t| { - let resolver = Arc::new(builder(UnbuiltProcedureBuilder::new(t.data())).resolver); + let resolver = Arc::new(builder(UnbuiltProcedureBuilder::from_builder(&t)).resolver); t(move |ctx, arg: LibraryArgs| { let resolver = resolver.clone(); @@ -169,7 +169,7 @@ where TResolver: Fn(Ctx, TArg, Uuid) -> TStream + Send + Sync + 'static, { self.subscription(key, |t| { - let resolver = Arc::new(builder(UnbuiltProcedureBuilder::new(t.data())).resolver); + let resolver = Arc::new(builder(UnbuiltProcedureBuilder::from_builder(&t)).resolver); t(move |ctx, arg: LibraryArgs| { // TODO(@Oscar): Upstream rspc work to allow this to work diff --git a/core/src/lib.rs b/core/src/lib.rs index db2ebf73f..dd0fb5bfa 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -48,8 +48,7 @@ impl Node { let data_dir = data_dir.as_ref(); #[cfg(debug_assertions)] let data_dir = data_dir.join("dev"); - - fs::create_dir_all(&data_dir).await?; + let _ = fs::create_dir_all(&data_dir).await; // This error is ignore because it throwing on mobile despite the folder existing. // dbg!(get_object_kind_from_extension("png")); @@ -59,31 +58,39 @@ impl Node { // )); // TODO: Make logs automatically delete after x time https://github.com/tokio-rs/tracing/pull/2169 - tracing_subscriber::registry() - .with( - EnvFilter::from_default_env() - .add_directive("warn".parse().expect("Error invalid tracing directive!")) - .add_directive( - "sdcore=debug" - .parse() - .expect("Error invalid tracing directive!"), - ) - .add_directive( - "server=debug" - .parse() - .expect("Error invalid tracing directive!"), - ) - .add_directive( - "desktop=debug" - .parse() - .expect("Error invalid tracing directive!"), - ), // .add_directive( - // "rspc=debug" - // .parse() - // .expect("Error invalid tracing directive!"), - // ), - ) - .with(fmt::layer().with_filter(CONSOLE_LOG_FILTER)) + let subscriber = tracing_subscriber::registry().with( + EnvFilter::from_default_env() + .add_directive("warn".parse().expect("Error invalid tracing directive!")) + .add_directive( + "sd-core=debug" + .parse() + .expect("Error invalid tracing directive!"), + ) + .add_directive( + "sd-core-mobile=debug" + .parse() + .expect("Error invalid tracing directive!"), + ) + .add_directive( + "server=debug" + .parse() + .expect("Error invalid tracing directive!"), + ) + .add_directive( + "desktop=debug" + .parse() + .expect("Error invalid tracing directive!"), + ), // .add_directive( + // "rspc=debug" + // .parse() + // .expect("Error invalid tracing directive!"), + // ), + ); + #[cfg(not(feature = "android"))] + let subscriber = subscriber.with(fmt::layer().with_filter(CONSOLE_LOG_FILTER)); + #[cfg(feature = "android")] + let subscriber = subscriber.with(tracing_android::layer("com.spacedrive.app").unwrap()); // TODO: This is not working + subscriber // .with( // Layer::default() // .with_writer(non_blocking) diff --git a/packages/client/package.json b/packages/client/package.json index 6e0ca3c10..91bab13d4 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -13,23 +13,15 @@ "lint": "TIMING=1 eslint src --fix", "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist" }, - "jest": { - "preset": "scripts/jest/node" - }, "dependencies": { - "@rspc/client": "^0.1.2", - "@rspc/react": "^0.1.2", + "@rspc/client": "^0.0.0-main-7c0a67c1", + "@rspc/react": "^0.0.0-main-7c0a67c1", "@sd/config": "workspace:*", - "@tanstack/react-query": "^4.10.1", - "eventemitter3": "^4.0.7", - "immer": "^9.0.15", - "lodash": "^4.17.21", + "@tanstack/react-query": "^4.12.0", "valtio": "^1.7.0", - "valtio-persist": "^1.0.2", - "zustand": "4.1.1" + "valtio-persist": "^1.0.2" }, "devDependencies": { - "@types/lodash": "^4.14.186", "@types/react": "^18.0.21", "scripts": "*", "tsconfig": "*", diff --git a/packages/client/src/core.ts b/packages/client/src/core.ts index f9a387595..db4e1ff94 100644 --- a/packages/client/src/core.ts +++ b/packages/client/src/core.ts @@ -14,6 +14,11 @@ export type Procedures = { { key: "locations.indexer_rules.get", input: LibraryArgs, result: IndexerRule } | { key: "locations.indexer_rules.list", input: LibraryArgs, result: Array } | { key: "locations.list", input: LibraryArgs, result: Array<{ id: number, pub_id: Array, node_id: number, name: string | null, local_path: string | null, total_capacity: number | null, available_capacity: number | null, filesystem: string | null, disk_type: number | null, is_removable: boolean | null, is_online: boolean, is_archived: boolean, date_created: string, node: Node }> } | + { key: "normi.composite", input: never, result: NormalisedCompositeId } | + { key: "normi.org", input: never, result: NormalisedOrganisation } | + { key: "normi.user", input: never, result: NormalisedUser } | + { key: "normi.userSync", input: never, result: NormalisedUser } | + { key: "normi.version", input: never, result: string } | { key: "tags.get", input: LibraryArgs, result: Tag | null } | { key: "tags.getExplorerData", input: LibraryArgs, result: ExplorerData } | { key: "tags.getForObject", input: LibraryArgs, result: Array } | @@ -92,6 +97,14 @@ export interface NodeConfig { version: string | null, id: string, name: string, export interface NodeState { version: string | null, id: string, name: string, p2p_port: number | null, data_path: string } +export interface NormalisedCompositeId { $type: string, $id: any, org_id: string, user_id: string } + +export interface NormalisedOrganisation { $type: string, $id: any, id: string, name: string, users: NormalizedVec, owner: NormalisedUser, non_normalised_data: Array } + +export interface NormalisedUser { $type: string, $id: any, id: string, name: string } + +export interface NormalizedVec { $type: string, edges: Array } + export interface Object { id: number, cas_id: string, integrity_checksum: string | null, name: string | null, extension: string | null, kind: number, size_in_bytes: string, key_id: number | null, hidden: boolean, favorite: boolean, important: boolean, has_thumbnail: boolean, has_thumbstrip: boolean, has_video_preview: boolean, ipfs_id: string | null, note: string | null, date_created: string, date_modified: string, date_indexed: string } export interface ObjectValidatorArgs { id: number, path: string } diff --git a/packages/client/src/hooks/useCurrentLibrary.tsx b/packages/client/src/hooks/useCurrentLibrary.tsx index d9d02e43f..ab8847296 100644 --- a/packages/client/src/hooks/useCurrentLibrary.tsx +++ b/packages/client/src/hooks/useCurrentLibrary.tsx @@ -1,7 +1,7 @@ import { PropsWithChildren, createContext, useCallback, useContext, useMemo } from 'react'; -import { proxy, useSnapshot } from 'valtio'; +import { proxy, subscribe, useSnapshot } from 'valtio'; -import { getExplorerStore, useBridgeQuery, useExplorerStore } from '../index'; +import { getExplorerStore, useBridgeQuery } from '../index'; // The name of the localStorage key for caching library data const libraryCacheLocalStorageKey = 'sd-library-list'; @@ -26,6 +26,10 @@ export function getLibraryIdRaw(): string | null { return currentLibraryUuidStore.id; } +export function onLibraryChange(func: (newLibraryId: string | null) => void) { + subscribe(currentLibraryUuidStore, () => func(currentLibraryUuidStore.id)); +} + // this is a hook to get the current library loaded into the UI. It takes care of a bunch of invariants under the hood. export const useCurrentLibrary = () => { const currentLibraryUuid = useSnapshot(currentLibraryUuidStore).id; diff --git a/packages/client/src/normi/index.ts b/packages/client/src/normi/index.ts new file mode 100644 index 000000000..b150ab6fb --- /dev/null +++ b/packages/client/src/normi/index.ts @@ -0,0 +1,60 @@ +// TODO(@Oscar): I wanna move Normi out of this repo and into rspc because it will make the code way more maintainable but right now I am unsure on the public API to make that possible. +import { CustomHooks } from '@rspc/client'; +// @ts-expect-error: // TODO(@Oscar): Fix types +import { __useMutation, __useQuery } from '@rspc/react/internal'; +import { useMemo } from 'react'; + +import { NormiOptions, getNormiCache, loadDataFromCache } from './utils'; + +export function normiCustomHooks( + { contextSharing }: NormiOptions, + nextHooks?: () => CustomHooks +): () => CustomHooks { + let normiCache = getNormiCache(contextSharing ?? false); + const next = nextHooks?.(); + + // TODO: Handle manual modifications to the query cache + // // queryClient.getQueryCache().subscribe(({ type, query }) => { + // // if (type === "added") { + // // console.log("ADDED", query.queryKey, query.state.data); + // // } else if (type === "updated") { + // // console.log("UPDATE", query.queryKey, query.state.data); + + // // const d = query.state.data; + // // if (Array.isArray(d)) { + // // d.forEach((f) => { + // // if (typeof f?.$id == "string") normyCache.set(f.$id, f); + // // }); + // // } + // // } else if (type === "removed") { + // // console.log("REMOVED", query.queryKey, query.state.data); + // // } + // // }); + + // TODO: Subscribe to backend for updates when things change + // - Subscribe for active queries + + return () => ({ + mapQueryKey: next?.mapQueryKey, + doQuery: next?.doQuery, + doMutation: next?.doMutation + // dangerous: { + // useQuery(keyAndInput, handler, opts) { + // const hook = __useQuery(keyAndInput, handler, opts); + // const data = useMemo(() => { + // return loadDataFromCache(hook.data, normiCache); + // }, [hook.data]); + + // return { + // ...hook, + // data + // }; + // }, + // useMutation(handler, opts) { + // const hook = __useMutation(handler, opts); + // // TODO: Normalize data before `onSuccess` or returning from `hook.data` + // return hook; + // } + // } + }); +} diff --git a/packages/client/src/normi/types.ts b/packages/client/src/normi/types.ts new file mode 100644 index 000000000..c87b3432b --- /dev/null +++ b/packages/client/src/normi/types.ts @@ -0,0 +1,27 @@ +import { ProcedureDef } from '@rspc/client'; + +// https://stackoverflow.com/a/54487392 +export type OmitDistributive = T extends any + ? T extends object + ? Id> + : T + : never; +export type Id = {} & { [P in keyof T]: T[P] }; // Cosmetic use only makes the tooltips expand the type can be removed +export type OmitRecursively = Omit< + { [P in keyof T]: OmitDistributive }, + K +>; + +/** + * is responsible for normalizing the Typescript type before the type is exposed back to the user. + * + * @internal + */ +export type Normalized = T extends any + ? { + key: T['key']; + // TODO: Typescript transformation for arrays + result: OmitRecursively; + input: T['input']; + } + : never; diff --git a/packages/client/src/normi/utils.ts b/packages/client/src/normi/utils.ts new file mode 100644 index 000000000..afa8b46e8 --- /dev/null +++ b/packages/client/src/normi/utils.ts @@ -0,0 +1,183 @@ +export type NormiCache = Map>; + +declare global { + interface Window { + normiCache?: NormiCache; + } +} + +export interface NormiOptions { + contextSharing?: boolean; +} + +export function getNormiCache(contextSharing: boolean): NormiCache { + if (contextSharing) { + if (window.normiCache === undefined) { + window.normiCache = new Map(); + } + + return window.normiCache; + } else { + return new Map(); + } +} + +export function getOrCreate(map: Map>, key: K): Map { + let m = map.get(key); + if (m === undefined) { + m = new Map(); + map.set(key, m); + } + return m; +} + +export function normaliseValue(value: any, normiCache: NormiCache): any { + if (value === null || value === undefined) { + return value; + } else if (typeof value === 'object') { + if ('$id' in value && '$type' in value) { + getOrCreate(normiCache, value.$type).set(value.$id, normaliseValueForStorage(value, true)); + delete value.$id; + delete value.$type; + } else if ('$type' in value && 'edges' in value) { + // TODO: Caching all the edges + value = (value.edges as any[]).map((v) => normaliseValue(v, normiCache)); + } + + // TODO: Optimise this to only check fields the backend marks as normalisable or on root + for (const [k, v] of Object.entries(value)) { + value[k] = normaliseValue(v, normiCache); + } + } + + return value; +} + +export function normaliseValueForStorage(value: any, rootElem: boolean): any { + if (value === null || value === undefined) { + return value; + } else if (typeof value === 'object') { + if ('$id' in value && '$type' in value) { + if (rootElem) { + let v = Object.assign({}, value); + delete v.$id; + delete v.$type; + + // TODO: Optimise this to only check fields the backend marks as normalisable or on root + for (const [k, vv] of Object.entries(v)) { + v[k] = normaliseValueForStorage(vv, false); + } + + return v; + } + + // TODO: Optimise this to only check fields the backend marks as normalisable or on root + for (const [k, v] of Object.entries(value)) { + value[k] = normaliseValueForStorage(v, false); + } + + return { + $id: value.$id, + $type: value.$type + }; + } else if ('$type' in value && 'edges' in value) { + return { + $type: value.$type, + edges: Object.values(value.edges as any[]).map((v) => v.$id) + }; + } + + // TODO: Optimise this to only check fields the backend marks as normalisable or on root + for (const [k, v] of Object.entries(value)) { + value[k] = normaliseValueForStorage(v, false); + } + } + + return value; +} + +export function recomputeNormalisedValueFromStorage(value: any, normiCache: NormiCache): any { + if (value === null || value === undefined) { + return value; + } else if (typeof value === 'object') { + if ('$id' in value && '$type' in value) { + value = normiCache.get(value.$type)!.get(value.$id); // TODO: Handle `undefined` + } else if ('$type' in value && 'edges' in value) { + value = (value.edges as any[]).map( + (id) => normiCache.get(value.$type)!.get(id) // TODO: Handle `undefined` + ); + } + + // TODO: Optimise this to only check fields the backend marks as normalisable or on root + for (const [k, v] of Object.entries(value)) { + value[k] = recomputeNormalisedValueFromStorage(v, normiCache); + } + } + + return value; +} + +// export function recomputeRQCache(queryClient: QueryClient, normiCache: NormiCache) { +// let c = queryClient.getQueryCache(); + +// // c.getAll().forEach((query) => { +// // const d = query.state.data; +// // if (Array.isArray(d)) { +// // queryClient.setQueryData( +// // query.queryKey, +// // d.map((f) => { +// // if (typeof f?.$id == "string" && normyCache.has(f?.$id)) { +// // return normyCache.get(f.$id); +// // } +// // return f; +// // }) +// // ); +// // } +// // }); +// } + +export function loadDataFromCache(value: any, normiCache: NormiCache): any { + // TODO: If can't be pulled out of the cache refetch + + if (value === null || value === undefined) { + return value; + } else if (typeof value === 'object') { + if ('$id' in value && '$type' in value) { + // if (rootElem) { + let v = Object.assign({}, value); + delete v.$id; + delete v.$type; + + // // TODO: Optimise this to only check fields the backend marks as normalisable or on root + // for (const [k, vv] of Object.entries(v)) { + // v[k] = normaliseValueForStorage(vv, false); + // } + + // return v; + // } + + // TODO: Optimise this to only check fields the backend marks as normalisable or on root + for (const [k, v] of Object.entries(value)) { + value[k] = normaliseValueForStorage(v, false); + } + + return v; // normiCache.get(v.$id)!; + } else if ('$type' in value && 'edges' in value) { + // TODO: This needs to be replicated in Typescript types + return []; + // { + // $type: value.$type, + // edges: Object.values(value.edges as any[]).map((v) => v.$id) + // }; + } + + // TODO: Optimise this to only check fields the backend marks as normalisable or on root + for (const [k, v] of Object.entries(value)) { + value[k] = normaliseValueForStorage(v, false); + } + } + + return value; +} + +// TODO: Optimistic updates diff --git a/packages/client/src/rspc.ts b/packages/client/src/rspc.ts index 086363f16..cc5ba34ba 100644 --- a/packages/client/src/rspc.ts +++ b/packages/client/src/rspc.ts @@ -1,12 +1,11 @@ import { ProcedureDef } from '@rspc/client'; -import { createReactQueryHooks } from '@rspc/react'; +import { internal_createReactHooksFactory } from '@rspc/react'; import { QueryClient } from '@tanstack/react-query'; import { LibraryArgs, Procedures } from './core'; import { getLibraryIdRaw } from './index'; - -export const queryClient = new QueryClient(); -export const rspc = createReactQueryHooks(); +import { normiCustomHooks } from './normi'; +import { Normalized } from './normi/types'; type NonLibraryProcedure = | Exclude }> @@ -17,7 +16,7 @@ type LibraryProcedures = Exclude< { input: never } >; -type MoreConstrainedQueries = T extends any +type StripLibraryArgsFromInput = T extends any ? T['input'] extends LibraryArgs ? { key: T['key']; @@ -27,30 +26,56 @@ type MoreConstrainedQueries = T extends any : never : never; -export const useBridgeQuery = rspc.customQuery>( - (keyAndInput) => keyAndInput as any -); +export const hooks = internal_createReactHooksFactory(); -export const useBridgeMutation = rspc.customMutation>( - (keyAndInput) => keyAndInput -); - -export const useLibraryQuery = rspc.customQuery< - MoreConstrainedQueries> ->((keyAndInput) => { - const library_id = getLibraryIdRaw(); - if (library_id === null) throw new Error('Attempted to do library query with no library set!'); - return [keyAndInput[0], { library_id, arg: keyAndInput[1] || null }]; +const nonLibraryHooks = hooks.createHooks< + Procedures, + // Normalized>, + // Normalized> + NonLibraryProcedure<'queries'>, + NonLibraryProcedure<'mutations'> +>({ + internal: { + customHooks: normiCustomHooks({ contextSharing: true }) + } }); -export const useLibraryMutation = rspc.customMutation< - MoreConstrainedQueries> ->((keyAndInput) => { - const library_id = getLibraryIdRaw(); - if (library_id === null) throw new Error('Attempted to do library query with no library set!'); - return [keyAndInput[0], { library_id, arg: keyAndInput[1] || null }]; +const libraryHooks = hooks.createHooks< + Procedures, + // Normalized>>, + // Normalized>>, + StripLibraryArgsFromInput>, + StripLibraryArgsFromInput>, + never +>({ + internal: { + customHooks: normiCustomHooks({ contextSharing: true }, () => { + return { + mapQueryKey: (keyAndInput) => { + const library_id = getLibraryIdRaw(); + if (library_id === null) + throw new Error('Attempted to do library operation with no library set!'); + return [keyAndInput[0], { library_id, arg: keyAndInput[1] || null }]; + }, + doMutation: (keyAndInput, next) => { + const library_id = getLibraryIdRaw(); + if (library_id === null) + throw new Error('Attempted to do library operation with no library set!'); + return next([keyAndInput[0], { library_id, arg: keyAndInput[1] || null }]); + } + }; + }) + } }); +export const queryClient = new QueryClient(); +export const rspc = hooks.createHooks(); + +export const useBridgeQuery = nonLibraryHooks.useQuery; +export const useBridgeMutation = nonLibraryHooks.useMutation; +export const useLibraryQuery = libraryHooks.useQuery; +export const useLibraryMutation = libraryHooks.useMutation; + export function useInvalidateQuery() { const context = rspc.useContext(); rspc.useSubscription(['invalidateQuery'], { diff --git a/packages/interface/package.json b/packages/interface/package.json index bc17a2b6a..1500c3741 100644 --- a/packages/interface/package.json +++ b/packages/interface/package.json @@ -19,77 +19,49 @@ "@headlessui/react": "^1.7.3", "@heroicons/react": "^2.0.12", "@loadable/component": "^5.15.2", - "@radix-ui/react-dialog": "^1.0.0", - "@radix-ui/react-dropdown-menu": "^1.0.0", - "@radix-ui/react-icons": "^1.1.1", "@radix-ui/react-progress": "^1.0.0", "@radix-ui/react-slider": "^1.0.0", - "@radix-ui/react-tabs": "^1.0.0", "@radix-ui/react-toast": "^1.0.0", "@radix-ui/react-tooltip": "^1.0.0", "@sd/assets": "workspace:*", "@sd/client": "workspace:*", "@sd/ui": "workspace:*", "@tailwindcss/forms": "^0.5.3", - "@tanstack/react-query": "^4.10.1", - "@tanstack/react-query-devtools": "^4.10.1", + "@tanstack/react-query": "^4.12.0", + "@tanstack/react-query-devtools": "^4.12.0", "@tanstack/react-virtual": "3.0.0-beta.18", "@vitejs/plugin-react": "^2.1.0", "autoprefixer": "^10.4.12", "byte-size": "^8.1.0", "clsx": "^1.2.1", - "date-fns": "^2.29.3", "dayjs": "^1.11.5", - "immer": "^9.0.15", - "jotai": "^1.8.4", - "lodash": "^4.17.21", - "moment": "^2.29.4", "phosphor-react": "^1.4.1", - "pretty-bytes": "^6.0.0", "react": "^18.2.0", "react-colorful": "^5.6.1", - "react-countup": "^6.3.1", "react-dom": "^18.2.0", - "react-dropzone": "^14.2.2", "react-error-boundary": "^3.1.4", "react-hook-form": "^7.36.1", - "react-hotkeys-hook": "^3.4.7", "react-json-view": "^1.21.3", - "react-loading-icons": "^1.1.0", "react-loading-skeleton": "^3.1.0", - "react-portal": "^4.2.2", - "react-query": "^3.39.2", "react-router": "6.4.2", "react-router-dom": "6.4.2", - "react-scrollbars-custom": "^4.1.1", - "react-spline": "^1.2.1", - "react-transition-group": "^4.4.5", - "react-virtuoso": "^2.19.1", "rooks": "^5.14.0", "tailwindcss": "^3.1.8", "use-count-up": "^3.0.1", "use-debounce": "^8.0.4", - "valtio": "^1.7.0", - "valtio-persist": "^1.0.2", - "zod": "^3.19.1", - "zustand": "4.1.1" + "valtio": "^1.7.0" }, "devDependencies": { "@sd/config": "workspace:*", "@types/babel-core": "^6.25.7", "@types/byte-size": "^8.1.0", "@types/loadable__component": "^5.13.4", - "@types/lodash": "^4.14.186", "@types/node": "^18.8.2", - "@types/pretty-bytes": "^5.2.0", "@types/react": "^18.0.21", "@types/react-dom": "^18.0.6", "@types/react-router-dom": "^5.3.3", - "@types/react-table": "^7.7.12", - "@types/react-window": "^1.8.5", "@types/tailwindcss": "^3.1.0", "@vitejs/plugin-react": "^1.3.1", - "concurrently": "^7.4.0", "prettier": "^2.7.1", "typescript": "^4.8.4", "vite": "^3.1.4", diff --git a/packages/interface/src/App.tsx b/packages/interface/src/App.tsx index 84e3fd591..8db615e57 100644 --- a/packages/interface/src/App.tsx +++ b/packages/interface/src/App.tsx @@ -22,7 +22,7 @@ export default function SpacedriveInterface() { {/* The `context={defaultContext}` part is required for this to work on Windows. Why, idk, don't question it */} - {import.meta.env.MODE === 'development' && ( + {import.meta.env.DEV && ( )} diff --git a/packages/interface/src/components/device/Stores.tsx b/packages/interface/src/components/device/Stores.tsx index e0a4e94a0..a7a72b388 100644 --- a/packages/interface/src/components/device/Stores.tsx +++ b/packages/interface/src/components/device/Stores.tsx @@ -1,19 +1,19 @@ -import create from 'zustand'; +import { useState } from 'react'; const getLocalStorage = (key: string) => JSON.parse(window.localStorage.getItem(key) || '{}'); const setLocalStorage = (key: string, value: any) => window.localStorage.setItem(key, JSON.stringify(value)); -type NodeState = { - isExperimental: boolean; - setIsExperimental: (experimental: boolean) => void; -}; +export function useNodeStore() { + const [state, setState] = useState( + (getLocalStorage('isExperimental') as boolean) === true || false + ); -export const useNodeStore = create((set) => ({ - isExperimental: (getLocalStorage('isExperimental') as boolean) === true || false, - setIsExperimental: (experimental: boolean) => - set((state) => { + return { + isExperimental: state, + setIsExperimental: (experimental: boolean) => { setLocalStorage('isExperimental', experimental); - return { ...state, isExperimental: experimental }; - }) -})); + setState(experimental); + } + }; +} diff --git a/packages/interface/src/components/dialog/DeleteLibraryDialog.tsx b/packages/interface/src/components/dialog/DeleteLibraryDialog.tsx index f84e7ad14..04718ac8b 100644 --- a/packages/interface/src/components/dialog/DeleteLibraryDialog.tsx +++ b/packages/interface/src/components/dialog/DeleteLibraryDialog.tsx @@ -1,14 +1,13 @@ import { useBridgeMutation } from '@sd/client'; import { Dialog } from '@sd/ui'; import { useQueryClient } from '@tanstack/react-query'; -import { useState } from 'react'; +import { PropsWithChildren, useState } from 'react'; -interface Props { - children: React.ReactNode; - libraryUuid: string; -} - -export default function DeleteLibraryDialog(props: Props) { +export default function DeleteLibraryDialog( + props: PropsWithChildren<{ + libraryUuid: string; + }> +) { const [openDeleteModal, setOpenDeleteModal] = useState(false); const queryClient = useQueryClient(); diff --git a/packages/interface/src/components/explorer/ExplorerContextMenu.tsx b/packages/interface/src/components/explorer/ExplorerContextMenu.tsx index e39fe4874..5d5c8f260 100644 --- a/packages/interface/src/components/explorer/ExplorerContextMenu.tsx +++ b/packages/interface/src/components/explorer/ExplorerContextMenu.tsx @@ -17,6 +17,7 @@ import { Trash, TrashSimple } from 'phosphor-react'; +import { PropsWithChildren } from 'react'; import { useSnapshot } from 'valtio'; const AssignTagMenuItems = (props: { objectId: number }) => { @@ -59,11 +60,7 @@ const AssignTagMenuItems = (props: { objectId: number }) => { ); }; -interface Props { - children: React.ReactNode; -} - -export default function ExplorerContextMenu(props: Props) { +export default function ExplorerContextMenu(props: PropsWithChildren) { const store = getExplorerStore(); return ( diff --git a/packages/interface/src/components/explorer/ExplorerOptionsPanel.tsx b/packages/interface/src/components/explorer/ExplorerOptionsPanel.tsx index 8ac7e08da..087354983 100644 --- a/packages/interface/src/components/explorer/ExplorerOptionsPanel.tsx +++ b/packages/interface/src/components/explorer/ExplorerOptionsPanel.tsx @@ -1,17 +1,17 @@ import { Select, SelectOption } from '@sd/ui'; -import { useState } from 'react'; +import { PropsWithChildren, useState } from 'react'; import Slider from '../primitive/Slider'; -const Heading: React.FC<{ children: React.ReactNode }> = ({ children }) => ( -
{children}
-); +function Heading({ children }: PropsWithChildren) { + return
{children}
; +} -const SubHeading: React.FC<{ children: React.ReactNode }> = ({ children }) => ( -
{children}
-); +function SubHeading({ children }: PropsWithChildren) { + return
{children}
; +} -export const ExplorerOptionsPanel: React.FC = () => { +export function ExplorerOptionsPanel() { const [sortBy, setSortBy] = useState('name'); const [stackBy, setStackBy] = useState('kind'); const [size, setSize] = useState([50]); @@ -44,4 +44,4 @@ export const ExplorerOptionsPanel: React.FC = () => { ); -}; +} diff --git a/packages/interface/src/components/explorer/VirtualizedList.tsx b/packages/interface/src/components/explorer/VirtualizedList.tsx index d470c8454..a424fa871 100644 --- a/packages/interface/src/components/explorer/VirtualizedList.tsx +++ b/packages/interface/src/components/explorer/VirtualizedList.tsx @@ -1,7 +1,7 @@ import { ExplorerLayoutMode, getExplorerStore, useExplorerStore } from '@sd/client'; import { ExplorerContext, ExplorerItem } from '@sd/client'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useKey, useOnWindowResize } from 'rooks'; diff --git a/packages/interface/src/components/explorer/inspector/Note.tsx b/packages/interface/src/components/explorer/inspector/Note.tsx index 5010f4fd9..bbc999894 100644 --- a/packages/interface/src/components/explorer/inspector/Note.tsx +++ b/packages/interface/src/components/explorer/inspector/Note.tsx @@ -1,8 +1,8 @@ import { useLibraryMutation } from '@sd/client'; import { Object as SDObject } from '@sd/client'; import { TextArea } from '@sd/ui'; -import debounce from 'lodash/debounce'; import { useCallback, useState } from 'react'; +import { useDebouncedCallback } from 'use-debounce'; import { Divider } from './Divider'; import { MetaItem } from './MetaItem'; @@ -19,19 +19,17 @@ export default function Note(props: Props) { const { mutate: fileSetNote } = useLibraryMutation('files.setNote'); - const debouncedNote = useCallback( + const debounce = useDebouncedCallback( (note: string) => - debounce( - () => - fileSetNote({ - id: props.data.id, - note - }), - 2000 - ), - [props.data.id, fileSetNote] + fileSetNote({ + id: props.data.id, + note + }), + 2000 ); + const debouncedNote = useCallback((note: string) => debounce(note), [props.data.id, fileSetNote]); + // when input is updated, cache note function handleNoteUpdate(e: React.ChangeEvent) { if (e.target.value !== note) { diff --git a/packages/interface/src/components/key/KeyManager.tsx b/packages/interface/src/components/key/KeyManager.tsx index f6854d1d2..e7b39230d 100644 --- a/packages/interface/src/components/key/KeyManager.tsx +++ b/packages/interface/src/components/key/KeyManager.tsx @@ -1,12 +1,6 @@ -import { Button, Input, Select, SelectOption, Tabs } from '@sd/ui'; -import clsx from 'clsx'; -import { Eject, EjectSimple, Plus } from 'phosphor-react'; -import { useState } from 'react'; +import { Tabs } from '@sd/ui'; -import { Toggle } from '../primitive'; import { DefaultProps } from '../primitive/types'; -import { Tooltip } from '../tooltip/Tooltip'; -import { Key } from './Key'; import { KeyList } from './KeyList'; import { KeyMounter } from './KeyMounter'; diff --git a/packages/interface/src/components/layout/Card.tsx b/packages/interface/src/components/layout/Card.tsx index 66112e180..72483d627 100644 --- a/packages/interface/src/components/layout/Card.tsx +++ b/packages/interface/src/components/layout/Card.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx'; -import { ReactNode } from 'react'; +import { PropsWithChildren } from 'react'; -export default function Card(props: { children: ReactNode; className?: string }) { +export default function Card(props: PropsWithChildren<{ className?: string }>) { return (
= (props) => { +export function Model( + props: PropsWithChildren<{ + full?: boolean; + }> +) { return (
= (props) => {
); -}; +} diff --git a/packages/interface/src/components/layout/Sidebar.tsx b/packages/interface/src/components/layout/Sidebar.tsx index 238fe8e9d..32469d580 100644 --- a/packages/interface/src/components/layout/Sidebar.tsx +++ b/packages/interface/src/components/layout/Sidebar.tsx @@ -5,6 +5,7 @@ import { LocationCreateArgs } from '@sd/client'; import { Button, CategoryHeading, Dropdown, OverlayPanel } from '@sd/ui'; import clsx from 'clsx'; import { CheckCircle, CirclesFour, Planet, WaveTriangle } from 'phosphor-react'; +import { PropsWithChildren } from 'react'; import { NavLink, NavLinkProps, useNavigate } from 'react-router-dom'; import { useOperatingSystem } from '../../hooks/useOperatingSystem'; @@ -14,7 +15,7 @@ import { JobsManager } from '../jobs/JobManager'; import RunningJobsWidget from '../jobs/RunningJobsWidget'; import { MacTrafficLights } from '../os/TrafficLights'; -export const SidebarLink = (props: NavLinkProps & { children: React.ReactNode }) => ( +export const SidebarLink = (props: PropsWithChildren) => ( {({ isActive }) => ( { title: string; description?: string; - children: React.ReactNode; mini?: boolean; } -export const InputContainer: React.FC = (props) => { +export function InputContainer(props: PropsWithChildren) { return (
= (props) => { {props.mini && props.children}
); -}; +} diff --git a/packages/interface/src/components/primitive/Tag.tsx b/packages/interface/src/components/primitive/Tag.tsx index 96f76c067..6e5c1d674 100644 --- a/packages/interface/src/components/primitive/Tag.tsx +++ b/packages/interface/src/components/primitive/Tag.tsx @@ -1,14 +1,13 @@ import clsx from 'clsx'; -import { ReactNode } from 'react'; +import { PropsWithChildren, ReactNode } from 'react'; import { DefaultProps } from './types'; export interface TagProps extends DefaultProps { - children: ReactNode; color: 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' | 'pink'; } -export function Tag(props: TagProps) { +export function Tag(props: PropsWithChildren) { return (
( +import { PropsWithChildren } from 'react'; + +export const SettingsContainer = ({ children }: PropsWithChildren) => (
{children}
); diff --git a/packages/interface/src/components/settings/SettingsHeader.tsx b/packages/interface/src/components/settings/SettingsHeader.tsx index b103493d8..dd1a22a73 100644 --- a/packages/interface/src/components/settings/SettingsHeader.tsx +++ b/packages/interface/src/components/settings/SettingsHeader.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import { ReactNode } from 'react'; +import { PropsWithChildren, ReactNode } from 'react'; interface SettingsHeaderProps { title: string; @@ -24,11 +24,13 @@ export const SettingsIcon = ({ component: Icon, ...props }: any) => ( ); -export const SettingsHeading: React.FC<{ className?: string; children: string }> = ({ +export function SettingsHeading({ children, className -}) => ( -
- {children} -
-); +}: PropsWithChildren<{ className?: string }>) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/interface/src/components/tooltip/Tooltip.tsx b/packages/interface/src/components/tooltip/Tooltip.tsx index 13538063c..eb2f4aa17 100644 --- a/packages/interface/src/components/tooltip/Tooltip.tsx +++ b/packages/interface/src/components/tooltip/Tooltip.tsx @@ -1,13 +1,18 @@ import * as TooltipPrimitive from '@radix-ui/react-tooltip'; +import { PropsWithChildren } from 'react'; export interface TooltipProps { - children: React.ReactNode; label: string; position?: 'top' | 'right' | 'bottom' | 'left'; className?: string; } -export const Tooltip = ({ children, label, position = 'bottom', className }: TooltipProps) => { +export const Tooltip = ({ + children, + label, + position = 'bottom', + className +}: PropsWithChildren) => { return ( diff --git a/packages/interface/src/components/transitions/SlideUp.tsx b/packages/interface/src/components/transitions/SlideUp.tsx index 9a16e5183..504cc922e 100644 --- a/packages/interface/src/components/transitions/SlideUp.tsx +++ b/packages/interface/src/components/transitions/SlideUp.tsx @@ -1,6 +1,7 @@ import { Transition } from '@headlessui/react'; +import { PropsWithChildren } from 'react'; -export default function SlideUp(props: { children: React.ReactNode }) { +export default function SlideUp(props: PropsWithChildren) { return ( ; - setCounterLastValue(key: string, value: number): void; -}>((set) => ({ +const counterStore = proxy({ counterLastValue: new Map(), - setCounterLastValue: (name, lastValue) => - set((state) => ({ - ...state, - counterLastValue: state.counterLastValue.set(name, lastValue) - })) -})); + setCounterLastValue: (name: string, lastValue: number) => + counterStore.counterLastValue.set(name, lastValue) +}); const useCounterState = (key: string) => { - const { counterLastValue, setCounterLastValue } = useCounterStore(); + const { counterLastValue, setCounterLastValue } = useSnapshot(counterStore); return { lastValue: counterLastValue.get(key), diff --git a/packages/interface/src/screens/Overview.tsx b/packages/interface/src/screens/Overview.tsx index 3dbf5a65b..53508aa82 100644 --- a/packages/interface/src/screens/Overview.tsx +++ b/packages/interface/src/screens/Overview.tsx @@ -1,5 +1,11 @@ -import { ExclamationCircleIcon, PlusIcon } from '@heroicons/react/24/solid'; -import { useBridgeQuery, useLibraryQuery, usePlatform } from '@sd/client'; +import { PlusIcon } from '@heroicons/react/24/solid'; +import { + onLibraryChange, + queryClient, + useCurrentLibrary, + useLibraryQuery, + usePlatform +} from '@sd/client'; import { Statistics } from '@sd/client'; import { Button, Input } from '@sd/ui'; import { Dialog } from '@sd/ui'; @@ -8,9 +14,8 @@ import clsx from 'clsx'; import { useEffect } from 'react'; import Skeleton from 'react-loading-skeleton'; import 'react-loading-skeleton/dist/skeleton.css'; -import create from 'zustand'; +import { proxy } from 'valtio'; -import { Device } from '../components/device/Device'; import useCounter from '../hooks/useCounter'; interface StatItemProps { @@ -26,42 +31,64 @@ const StatItemNames: Partial> = { total_bytes_free: 'Free space' }; -type OverviewStats = Partial>; -type OverviewState = { - overviewStats: OverviewStats; - setOverviewStat: (name: keyof OverviewStats, newValue: string) => void; - setOverviewStats: (stats: OverviewStats) => void; -}; +const displayableStatItems = Object.keys(StatItemNames) as unknown as keyof typeof StatItemNames; -export const useOverviewState = create((set) => ({ - overviewStats: {}, - setOverviewStat: (name, newValue) => - set((state) => ({ - ...state, - overviewStats: { - ...state.overviewStats, - [name]: newValue +export const state = proxy({ + lastRenderedLibraryId: undefined as string | undefined +}); + +onLibraryChange((newLibraryId) => { + state.lastRenderedLibraryId = undefined; + + // TODO: Fix + // This is bad solution to the fact that the hooks don't rerun when opening a library that is already cached. + // This is because the count never drops back to zero as their is no loading state given the libraries data was already in the React Query cache. + queryClient.setQueryData( + [ + 'library.getStatistics', + { + library_id: newLibraryId, + arg: null } - })), - setOverviewStats: (stats) => - set((state) => ({ - ...state, - overviewStats: stats - })) -})); + ], + { + id: 0, + date_captured: '', + total_bytes_capacity: '0', + preview_media_bytes: '0', + library_db_size: '0', + total_object_count: 0, + total_bytes_free: '0', + total_bytes_used: '0', + total_unique_bytes: '0' + } + ); + queryClient.invalidateQueries(['library.getStatistics']); +}); const StatItem: React.FC = (props) => { + const { library } = useCurrentLibrary(); const { title, bytes = '0', isLoading } = props; - // const appProps = useContext(AppPropsContext); - const size = byteSize(+bytes); - const count = useCounter({ name: title, - end: +size.value + end: +size.value, + duration: state.lastRenderedLibraryId === library?.uuid ? 0 : undefined, + saveState: false }); + if (count !== 0 && count == +size.value) { + state.lastRenderedLibraryId = library?.uuid; + } + + useEffect(() => { + return () => { + if (count !== 0) state.lastRenderedLibraryId = library?.uuid; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return (
= (props) => { export default function OverviewScreen() { const platform = usePlatform(); - const { data: libraryStatistics, isLoading: isStatisticsLoading } = useLibraryQuery([ - 'library.getStatistics' - ]); - const { data: nodeState } = useBridgeQuery(['getNode']); + const { library } = useCurrentLibrary(); + const { data: overviewStats, isLoading: isStatisticsLoading } = useLibraryQuery( + ['library.getStatistics'], + { + initialData: { + id: 0, + date_captured: '', + total_bytes_capacity: '0', + preview_media_bytes: '0', + library_db_size: '0', + total_object_count: 0, + total_bytes_free: '0', + total_bytes_used: '0', + total_unique_bytes: '0' + } + } + ); - const { overviewStats, setOverviewStats } = useOverviewState(); - - // get app props from context - useEffect(() => { - const newStatistics: OverviewStats = { - total_bytes_capacity: '0', - preview_media_bytes: '0', - library_db_size: '0', - total_object_count: '0', - total_bytes_free: '0', - total_bytes_used: '0', - total_unique_bytes: '0' - }; - - Object.entries((libraryStatistics as Statistics) || {}).forEach(([key, value]) => { - newStatistics[key as keyof Statistics] = `${value}`; - }); - - setOverviewStats(newStatistics); - }, [platform, libraryStatistics, setOverviewStats]); - - const displayableStatItems = Object.keys(StatItemNames) as unknown as keyof typeof StatItemNames; + console.log(overviewStats); return (
@@ -129,12 +148,12 @@ export default function OverviewScreen() {
{/* STAT CONTAINER */}
- {Object.entries(overviewStats).map(([key, value]) => { + {Object.entries(overviewStats || []).map(([key, value]) => { if (!displayableStatItems.includes(key)) return null; return ( */} {/* */} +
); } + +// TODO(@Oscar): Remove this +function Debug() { + // const org = useBridgeQuery(['normi.org']); + // console.log(org.data); + + return null; +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 98639e5c4..078fe98c3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -35,12 +35,12 @@ "react-loading-icons": "^1.1.0", "react-router-dom": "6.4.2", "react-spring": "^9.5.5", - "storybook": "^6.5.12", "tailwind-styled-components": "2.1.7", - "tailwindcss": "^3.1.8", "tailwindcss-radix": "^2.6.0" }, "devDependencies": { + "tailwindcss": "^3.1.8", + "storybook": "^6.5.12", "@babel/core": "^7.19.3", "@sd/config": "workspace:*", "@storybook/addon-actions": "^6.5.12", diff --git a/packages/ui/src/Dropdown.tsx b/packages/ui/src/Dropdown.tsx index 84bde7809..5a3b33b90 100644 --- a/packages/ui/src/Dropdown.tsx +++ b/packages/ui/src/Dropdown.tsx @@ -1,7 +1,7 @@ import { Menu, Transition } from '@headlessui/react'; import { ChevronDownIcon } from '@heroicons/react/24/solid'; import clsx from 'clsx'; -import React from 'react'; +import { Fragment, PropsWithChildren } from 'react'; import { Link } from 'react-router-dom'; import { Button } from './Button'; @@ -12,7 +12,7 @@ export type DropdownItem = ( icon?: any; selected?: boolean; to?: string; - wrapItemComponent?: React.FC<{ children: React.ReactNode }>; + wrapItemComponent?: React.FC; } | { name: string; @@ -21,7 +21,7 @@ export type DropdownItem = ( selected?: boolean; onPress?: () => any; to?: string; - wrapItemComponent?: React.FC<{ children: React.ReactNode }>; + wrapItemComponent?: React.FC; } )[]; @@ -65,7 +65,7 @@ export const Dropdown: React.FC = (props) => { = (props) => { {item.map((button, index) => ( {({ active }) => { - const WrappedItem = button.wrapItemComponent + const WrappedItem: any = button.wrapItemComponent ? button.wrapItemComponent : (props: React.PropsWithChildren) => <>{props.children}; diff --git a/packages/ui/src/Input.tsx b/packages/ui/src/Input.tsx index 4d78dca8d..e96b5f331 100644 --- a/packages/ui/src/Input.tsx +++ b/packages/ui/src/Input.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import { forwardRef } from 'react'; +import { PropsWithChildren, forwardRef } from 'react'; const variants = { default: ` @@ -64,8 +64,10 @@ export const TextArea = ({ size, ...props }: TextAreaProps) => { ); }; -export const Label: React.FC<{ slug?: string; children: string }> = (props) => ( - -); +export function Label(props: PropsWithChildren<{ slug?: string }>) { + return ( + + ); +} diff --git a/packages/ui/src/Select.tsx b/packages/ui/src/Select.tsx index d2b3ea82b..051105353 100644 --- a/packages/ui/src/Select.tsx +++ b/packages/ui/src/Select.tsx @@ -2,17 +2,16 @@ import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/s import * as SelectPrimitive from '@radix-ui/react-select'; import { ReactComponent as ChevronDouble } from '@sd/assets/svgs/chevron-double.svg'; import clsx from 'clsx'; -import { CaretDown } from 'phosphor-react'; +import { PropsWithChildren } from 'react'; interface SelectProps { value: string; size?: 'sm' | 'md' | 'lg'; className?: string; onChange: (value: string) => void; - children: React.ReactNode; } -export function Select(props: SelectProps) { +export function Select(props: PropsWithChildren) { return ( ) { return (