diff --git a/.gitignore b/.gitignore index a547bf3..6c92f0b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +*.zip # Editor directories and files .vscode/* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e53c418 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,101 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] + +### Added + +- QDN filesystem backups now include both the filesystem snapshot and the private resource index so another node can restore the same local state. +- Startup now compares the local filesystem state against the published QDN backup and prompts to load the QDN version when they differ. +- Out-of-date QDN publish state now appears as a small notification badge instead of opening the publish diff immediately. +- Manual publish/import actions now clear stale publish notifications and keep the sync baseline aligned with the current filesystem state. + +### Changed + +- The `Sync filesystem backup` toggle now controls publish reminders, not startup restore checks. +- Auto-sync no longer interrupts the user as soon as a mismatch is detected; the diff is now opened from the notification badge. +- Private file display labels are kept separate from filesystem identity so private entries do not turn into phantom tree items. +- Filesystem size summaries continue to report the summed sizes of represented files, which can look large even though the QDN backup payload is metadata-only. +- Group mode now prefers the embedded/current group hint when Q-Manager loads inside Q-Chat, and the group picker shows per-group file counts sorted by the most-populated group first. + +### Fixed + +- Deleted private files no longer reappear in sync prompts because publish baselines are normalized from the current filesystem tree plus private index. +- Restore and publish diffs now compare the filesystem snapshot and private resource index together, which keeps backup decisions accurate. +- Startup restore prompting now works consistently even when auto-sync is disabled. +- Large private media previews remain separate from the backup snapshot flow, so preview cost is not the same thing as backup publish size. +- Successful private previews now refresh the cached thumbnail in the private index, so a better preview can replace a stale image thumbnail. +- Group publish actions now normalize group IDs before encryption and publish, which avoids string/number mismatches when selecting a group. +- Public group publishes now stay unencrypted, while private groups use `ENCRYPT_QORTAL_GROUP_DATA` and write matching entries into the private resource index. +- Public group files are no longer treated like private resources just because they belong to a group, so previews, thumbnails, and deletes follow the correct path. +- Group embed links and published identifiers now follow the Q-Chat-compatible `grp-q-manager_0_group_...` / `grp-q-manager_1_group_...` convention, which keeps private group embeds decryptable and public group files unencrypted. +- Group embed links now also carry the selected `groupId` as a compatibility hint, since the current Q-Chat image embed path decrypts against the active group context. +- Delete-from-QDN now batches multiple selected files through the multi-publish request instead of republishing tombstones one at a time. +- Discovery/import now prefers the group ID encoded in the identifier when reconstructing previously published group files, so they land in the correct group bucket instead of the first selected group. +- Private embed creation now prefers the file node's stored sharing key before querying resource properties, which keeps freshly published private image embeds consistent with the key used to encrypt them. +- Private and private-group publishes now encrypt the raw file bytes once and publish them as externally encrypted data, while file metadata continues to live in the private resource index. +- Right-click file menus now include a `copy embed link` action that uses the file's default name and the same embed-link logic as the file details dialog. +- The app shell now keeps its dark background filled in embedded Q-Chat layouts so the lower page area does not flash white while scrolling. +- Multi-file publish now updates the live filesystem tree incrementally, so all files in a batch stay visible after group/private publishes. +- The bulk-move modal now tolerates the group-map fallback state and always renders the active group's folder tree instead of assuming the tree is already an array. + +### Notes + +- The QDN backup is still an encrypted metadata snapshot, not the raw contents of every file in the filesystem. +- Legacy QDN backups remain readable. + +## [0.2.0] - 2026-02-28 + +### Added + +- Filesystem persistence now supports IndexedDB with localStorage backup/fallback behavior. +- Versioned/timestamped storage records for safer persistence reconciliation. +- QDN filesystem structure sync actions. +- Option to publish filesystem structure to QDN. +- Option to import filesystem structure from QDN. +- Option to discover previously published Q-Manager resources and import them into the UI. +- Automatic import destination folder: `Recovered Imports`. +- Multi-select file workflow with per-item checkboxes in the grid. +- Bulk selected-file action mode in bottom controls: `Move`, `Remove`, `Delete from QDN`. +- Bulk move modal for selected files with target folder selection. +- QDN tombstone delete flow by republishing each selected file identifier with `data64` for `"d"`. +- File preview support from the main grid. +- Right-click context menu action: `preview`. +- Double-click file behavior to open preview. +- Optional `Show thumbnails` checkbox above the main action controls. +- Image thumbnails in file tiles when thumbnail mode is enabled. +- File `displayName` support for UI labels (separate from published `name` / `identifier`). +- Right-click context menu action: `More info` modal with full known file metadata dump. +- Optional live metadata fetch from QDN resource properties (`GET_QDN_RESOURCE_PROPERTIES`) from the `More info` modal. +- Automatic metadata hydration back into file nodes from fetched properties (size, mime, display filename when appropriate). +- Selected-files footer now shows aggregated file size with unknown-count fallback. +- Per-file size display in selected file details dialog. +- File pinning support (`pin file` / `unpin file`) persisted in filesystem data. +- Visual pin badge on pinned file tiles. +- Extension-based preview inference and text preview mode (`.txt`, `.md`, `.json`, etc.). + +### Changed + +- IndexedDB is treated as the primary storage source, with localStorage retained as backup. +- Storage load flow now heals missing/failed IndexedDB state from localStorage fallback data when needed. +- Storage save flow now writes through a combined helper to reduce drift between stores. +- Folder/file tile visuals were refreshed for stronger readability and hierarchy. +- File rename behavior now updates `displayName` for files (folder rename behavior remains structural). +- Selection is cleared when switching top-level mode tabs (`public` / `private` / `groups`) or selected group. +- Missing/discovered file imports now attempt property hydration and use resolved filename for display labels. +- `remove directory` context action now uses a delete icon (pin icon reserved for pinning behavior). + +### Fixed + +- `utils.ts` TypeScript typing issues and Promise/file handling edge cases. +- Publish service default selection precedence bug in single publish flow. +- Publish service dropdown menu rendering/opacity issues in modal context. +- Better error fallback behavior for preview/thumbnails when media cannot be loaded. +- Custom button component now honors the `disabled` prop. +- Discovery/import now ignores tombstoned QDN resources by filtering very small (delete marker) resource sizes. +- Preview fallback now handles text-like resources better when MIME metadata is missing by deriving from filename extension. + +### Notes + +- QDN publish/import works with the in-memory filesystem state, not direct storage backend snapshots. diff --git a/QAPP_CORE_MIGRATION.md b/QAPP_CORE_MIGRATION.md new file mode 100644 index 0000000..bc74a93 --- /dev/null +++ b/QAPP_CORE_MIGRATION.md @@ -0,0 +1,39 @@ +# qapp-core Migration Notes + +## Status + +- Branch: `spike/qapp-core-migration` +- Started migration by introducing a single request adapter: + - `src/qapp/request.ts` +- Major runtime request callsites now route through: + - `requestQortal(...)` + +## Adapter Behavior + +`requestQortal` resolves provider in this order: + +1. `window.qappCore.request` / `window.QAppCore.request` / `window.qappCore.qortalRequest` +2. `window.qapp.request` / `window.qapp.qortalRequest` +3. legacy global `qortalRequest` + +If none are available, it throws an explicit provider error. + +## Migrated Files + +- `src/App.jsx` +- `src/Manager.tsx` +- `src/storage.ts` +- `src/File.tsx` +- `src/ContextMenuPinnedFiles.tsx` +- `src/actions/PUBLISH_QDN_RESOURCE.jsx` +- `src/actions/PUBLISH_MULTIPLE_QDN_RESOURCES.jsx` +- `src/actions/CREATE_POLL.jsx` +- `src/actions/VOTE_ON_POLL.jsx` +- `src/actions/OPEN_NEW_TAB.jsx` + +## Next Steps + +1. Add actual `qapp-core` package wiring once target API surface is confirmed. +2. Replace direct feature assumptions with typed qapp-core service modules. +3. Add provider diagnostics in UI (which provider is active). +4. Add branch-level smoke tests for publish, decrypt/encrypt, filesystem import/export. diff --git a/dist.zip b/dist.zip deleted file mode 100644 index 6bb7a7f..0000000 Binary files a/dist.zip and /dev/null differ diff --git a/package-lock.json b/package-lock.json index ecd162f..671e88b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "QManager", - "version": "0.0.0", + "version": "0.2.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "QManager", - "version": "0.0.0", + "version": "0.2.0", "dependencies": { "@dnd-kit/core": "^6.2.0", "@dnd-kit/sortable": "^9.0.0", @@ -71,6 +71,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.0", @@ -257,6 +258,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", + "peer": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -321,6 +323,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.2.0.tgz", "integrity": "sha512-KVK/CJmaYGTxTPU6P0+Oy4itgffTUa80B8317sXzfOr1qUzSL29jE7Th11llXiu2haB7B9Glpzo2CDElin+geQ==", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -448,6 +451,7 @@ "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.13.5.tgz", "integrity": "sha512-gnOQ+nGLPvDXgIx119JqGalys64lhMdnNQA9TMxhDA4K0Hq5+++OE20Zs5GxiCV9r814xQ2K5WmtofSpHVW6BQ==", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -938,6 +942,7 @@ "version": "5.16.7", "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.16.7.tgz", "integrity": "sha512-cwwVQxBhK60OIOqZOVLFt55t01zmarKJiJUWbk0+8s/Ix5IaUzAShqlJchxsIQ4mSrWqgcKCCXKtIlG5H+/Jmg==", + "peer": true, "dependencies": { "@babel/runtime": "^7.23.9", "@mui/core-downloads-tracker": "^5.16.7", @@ -1158,6 +1163,7 @@ "version": "18.3.12", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -1296,6 +1302,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001669", "electron-to-chromium": "^1.5.41", @@ -1422,7 +1429,8 @@ "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "peer": true }, "node_modules/debug": { "version": "4.3.7", @@ -1684,7 +1692,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "peer": true, "dependencies": { "react-is": "^16.7.0" } @@ -1692,8 +1699,7 @@ "node_modules/hoist-non-react-statics/node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "peer": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/import-fresh": { "version": "3.3.0", @@ -2089,6 +2095,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2100,6 +2107,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -2435,6 +2443,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.5.tgz", "integrity": "sha512-ifW3Lb2sMdX+WU91s3R0FyQlAyLxOzCSCP37ujw0+r5POeHPwe6udWVIElKQq8gk3t7b8rkmvqC6IHBpCff4GQ==", "dev": true, + "peer": true, "dependencies": { "esbuild": "^0.18.10", "postcss": "^8.4.27", @@ -2630,6 +2639,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", "dev": true, + "peer": true, "requires": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.0", @@ -2763,6 +2773,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", + "peer": true, "requires": { "regenerator-runtime": "^0.14.0" } @@ -2812,6 +2823,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.2.0.tgz", "integrity": "sha512-KVK/CJmaYGTxTPU6P0+Oy4itgffTUa80B8317sXzfOr1qUzSL29jE7Th11llXiu2haB7B9Glpzo2CDElin+geQ==", + "peer": true, "requires": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2920,6 +2932,7 @@ "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.13.5.tgz", "integrity": "sha512-gnOQ+nGLPvDXgIx119JqGalys64lhMdnNQA9TMxhDA4K0Hq5+++OE20Zs5GxiCV9r814xQ2K5WmtofSpHVW6BQ==", + "peer": true, "requires": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -3168,6 +3181,7 @@ "version": "5.16.7", "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.16.7.tgz", "integrity": "sha512-cwwVQxBhK60OIOqZOVLFt55t01zmarKJiJUWbk0+8s/Ix5IaUzAShqlJchxsIQ4mSrWqgcKCCXKtIlG5H+/Jmg==", + "peer": true, "requires": { "@babel/runtime": "^7.23.9", "@mui/core-downloads-tracker": "^5.16.7", @@ -3273,6 +3287,7 @@ "version": "18.3.12", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", + "peer": true, "requires": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -3362,6 +3377,7 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", "dev": true, + "peer": true, "requires": { "caniuse-lite": "^1.0.30001669", "electron-to-chromium": "^1.5.41", @@ -3450,7 +3466,8 @@ "csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "peer": true }, "debug": { "version": "4.3.7", @@ -3638,7 +3655,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "peer": true, "requires": { "react-is": "^16.7.0" }, @@ -3646,8 +3662,7 @@ "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "peer": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" } } }, @@ -3920,6 +3935,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "requires": { "loose-envify": "^1.1.0" } @@ -3928,6 +3944,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "requires": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -4148,6 +4165,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.5.tgz", "integrity": "sha512-ifW3Lb2sMdX+WU91s3R0FyQlAyLxOzCSCP37ujw0+r5POeHPwe6udWVIElKQq8gk3t7b8rkmvqC6IHBpCff4GQ==", "dev": true, + "peer": true, "requires": { "esbuild": "^0.18.10", "fsevents": "~2.3.2", diff --git a/package.json b/package.json index 2d78ea1..d61efaa 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "QManager", + "name": "Q-Manager", "private": true, - "version": "0.0.0", + "version": "0.2.7", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.css b/src/App.css index 3b7ead5..e41e868 100644 --- a/src/App.css +++ b/src/App.css @@ -1,14 +1,19 @@ #root { - width: 100vw; - height: 100vh; - margin: 0 auto; + width: 100%; + min-height: 100vh; + margin: 0; + background-color: rgb(39, 40, 44); } .container { display: flex; flex-direction: column; - justify-content: center; - + justify-content: flex-start; + align-items: stretch; + min-height: 100vh; + width: 100%; + background-color: rgb(39, 40, 44); + box-sizing: border-box; } .flex-row { @@ -54,4 +59,4 @@ button { outline: none; border: none; -} \ No newline at end of file +} diff --git a/src/App.jsx b/src/App.jsx index f6735df..41822e3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,14 +1,9 @@ import { useCallback, useEffect, useState } from "react"; -import { Box, CircularProgress, CssBaseline, MenuItem, Select, ThemeProvider, Tooltip, Typography, createTheme } from "@mui/material"; +import { Box, CircularProgress, CssBaseline, ThemeProvider, Typography, createTheme } from "@mui/material"; import "./App.css"; -import Container from "./components/Container"; -import QSandboxLogo from "./assets/images/QSandboxLogo.png"; -import InfoIcon from "@mui/icons-material/Info"; -import { categories } from "./constants"; -import { ShowCategories } from "./ShowCategories"; -import { ShowAction } from "./ShowAction"; import { Manager } from "./Manager"; import { Toaster } from "react-hot-toast"; +import { requestQortal } from "./qapp/request"; const theme = createTheme({ palette: { @@ -33,29 +28,86 @@ function App() { const [myAddress, setMyaddress] = useState('') const [isLoading, setIsloading] = useState(true) const [groups, setGroups] = useState([]) + const [ownedNames, setOwnedNames] = useState([]) + const [activeName, setActiveName] = useState("") + + const normalizeName = useCallback((entry) => { + if (typeof entry === "string") return entry.trim(); + if (entry && typeof entry === "object" && typeof entry.name === "string") { + return entry.name.trim(); + } + return ""; + }, []); + + const extractPrimaryName = useCallback((payload) => { + if (Array.isArray(payload)) { + for (const item of payload) { + const nameValue = normalizeName(item); + if (nameValue) return nameValue; + } + return ""; + } + return normalizeName(payload); + }, [normalizeName]); const askForAccountInformation = useCallback(async () => { try { - const account = await qortalRequest({ + const account = await requestQortal({ action: "GET_USER_ACCOUNT", }); if(account?.address){ - const nameData = await qortalRequest({ + let names = [] + const nameData = await requestQortal({ action: "GET_ACCOUNT_NAMES", address: account.address, }); - setMyaddress({...account, name: nameData[0] || ""}) + if (Array.isArray(nameData)) { + names = nameData.map((entry) => normalizeName(entry)).filter(Boolean); + } else { + const singleName = normalizeName(nameData); + if (singleName) { + names = [singleName]; + } + } + + let primaryName = ""; + try { + const primaryNameData = await requestQortal({ + action: "GET_PRIMARY_NAME", + address: account.address, + }); + primaryName = extractPrimaryName(primaryNameData); + } catch (error) { + try { + const primaryNameData = await requestQortal({ + action: "GET_PRIMARY_NAME", + }); + primaryName = extractPrimaryName(primaryNameData); + } catch (innerError) {} + } + + const resolvedName = primaryName || names[0] || ""; + setOwnedNames(names); + setActiveName((prev) => { + if (prev && names.includes(prev)) return prev; + return resolvedName; + }); + setMyaddress({ + ...account, + name: resolvedName ? { name: resolvedName } : "", + names: names.map((name) => ({ name })), + }) } } catch (error) { console.error(error); } finally { setIsloading(false) } - }, []); + }, [extractPrimaryName, normalizeName]); const getGroups = useCallback(async (address) => { try { const res = await fetch(`/groups/member/${address}`); - + const data = await res.json() setGroups(data) @@ -69,9 +121,6 @@ function App() { useEffect(()=> { askForAccountInformation() }, [askForAccountInformation]) - const handleClose = useCallback(()=> { - setSelectedAction(null) - }, []) useEffect(()=> { if(myAddress?.address){ @@ -79,6 +128,22 @@ function App() { } }, [myAddress?.address]) + useEffect(() => { + if (!myAddress?.address) return; + setMyaddress((prev) => { + if (!prev?.address) return prev; + const nextName = activeName ? { name: activeName } : ""; + if (prev?.name?.name === nextName?.name) return prev; + return { + ...prev, + name: nextName, + }; + }); + }, [activeName, myAddress?.address]); + + const resolvedActiveName = + activeName || myAddress?.name?.name || ownedNames[0] || ""; + return ( @@ -86,17 +151,6 @@ function App() {
{isLoading && ( - - - - )} - {!isLoading && !myAddress?.name?.name && ( + + + )} + {!isLoading && !resolvedActiveName && ( + - To use Q-Manager you need a registered Qortal Name - + }}> + To use Q-Manager you need a registered Qortal Name + - )} - {!isLoading && myAddress?.name?.name && ( - - - )} - + )} + {!isLoading && !!resolvedActiveName && ( + + )} +
); } export default App; - - diff --git a/src/ContextMenuPinnedFiles.tsx b/src/ContextMenuPinnedFiles.tsx index edab4e3..c1a779b 100644 --- a/src/ContextMenuPinnedFiles.tsx +++ b/src/ContextMenuPinnedFiles.tsx @@ -1,253 +1,622 @@ -import React, { useState, useRef } from 'react'; -import { Box, List, ListItem, ListItemButton, ListItemIcon, ListItemText, Menu, MenuItem, Modal, Typography, styled } from '@mui/material'; -import PushPinIcon from '@mui/icons-material/PushPin'; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { + Box, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Modal, + Typography, + styled, +} from "@mui/material"; +import PushPinIcon from "@mui/icons-material/PushPin"; import FolderIcon from "@mui/icons-material/Folder"; -import DeleteIcon from '@mui/icons-material/Delete'; -import DriveFileMoveIcon from '@mui/icons-material/DriveFileMove'; -import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline'; +import DeleteIcon from "@mui/icons-material/Delete"; +import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove"; +import DriveFileRenameOutlineIcon from "@mui/icons-material/DriveFileRenameOutline"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import { isPrivateGroupQManagerIdentifier } from "./utils"; +import { requestQortal } from "./qapp/request"; + +type MenuPosition = { + mouseX: number; + mouseY: number; +}; + +type ResourceProperties = Record; + +type FileNode = { + type?: string; + name?: string; + displayName?: string; + qortalName?: string; + identifier?: string; + service?: string; + mimeType?: string; + sizeInBytes?: number; + size?: number; + fileSize?: number; + dataSize?: number; + createdSize?: number; + totalSize?: number; + encryptionType?: string; + group?: number; + groupId?: number; + title?: string; + children?: FileNode[]; + [key: string]: any; +}; + +const isEncryptedResourceNode = (node: FileNode | null | undefined) => { + const service = + typeof node?.service === "string" ? node.service.toUpperCase() : ""; + const encryptionType = + typeof node?.encryptionType === "string" + ? node.encryptionType.toLowerCase() + : ""; + const identifier = + typeof node?.identifier === "string" ? node.identifier.toLowerCase() : ""; + + return ( + encryptionType.includes("private") || + isPrivateGroupQManagerIdentifier(identifier) || + service.includes("_PRIVATE") || + identifier.startsWith("p-") || + identifier.startsWith("pvt-") + ); +}; + +type ContextMenuPinnedFilesProps = { + children?: React.ReactNode; + removeFile: () => void; + removeDirectory: () => void; + type?: string; + rename: () => void; + fileSystem?: FileNode[]; + moveNode: (...args: any[]) => void; + currentPath: string[]; + item: FileNode; + onPreview?: () => void; + onCopyEmbedLink?: () => void; + onHydrateMetadata?: (metadata: Record) => void; + pinned?: boolean; + onTogglePin?: () => void; +}; const CustomStyledMenu = styled(Menu)(({ theme }) => ({ - '& .MuiPaper-root': { - backgroundColor: '#f9f9f9', - borderRadius: '12px', - padding: theme.spacing(1), - boxShadow: '0 5px 15px rgba(0, 0, 0, 0.2)', - }, - '& .MuiMenuItem-root': { - fontSize: '14px', - color: '#444', - transition: '0.3s background-color', - '&:hover': { - backgroundColor: '#f0f0f0', - }, + "& .MuiPaper-root": { + backgroundColor: "#f9f9f9", + borderRadius: "12px", + padding: theme.spacing(1), + boxShadow: "0 5px 15px rgba(0, 0, 0, 0.2)", + }, + "& .MuiMenuItem-root": { + fontSize: "14px", + color: "#444", + transition: "0.3s background-color", + "&:hover": { + backgroundColor: "#f0f0f0", }, + }, })); -export const ContextMenuPinnedFiles = ({ children, removeFile, removeDirectory, type, rename, fileSystem, -moveNode, currentPath, item }) => { - const [menuPosition, setMenuPosition] = useState(null); - const longPressTimeout = useRef(null); - const maxHoldTimeout = useRef(null); - const preventClick = useRef(false); - const [showMoveModal, setShowMoveModal] = useState(false); - const [targetPath, setTargetPath] = useState([]); - const startTouchPosition = useRef({ x: 0, y: 0 }); // Track initial touch position - const handleContextMenu = (event) => { - event.preventDefault(); - event.stopPropagation(); - preventClick.current = true; - setMenuPosition({ - mouseX: event.clientX, - mouseY: event.clientY, - }); - }; +const getValueByKeys = ( + source: Record | null | undefined, + keys: string[] = [] +) => { + if (!source || typeof source !== "object") return undefined; + for (const key of keys) { + if (source[key] !== undefined && source[key] !== null) { + return source[key]; + } + } + return undefined; +}; - const handleTouchStart = (event) => { - - const { clientX, clientY } = event.touches[0]; - startTouchPosition.current = { x: clientX, y: clientY }; - - longPressTimeout.current = setTimeout(() => { - preventClick.current = true; - - event.stopPropagation(); - setMenuPosition({ - mouseX: clientX, - mouseY: clientY, - }); - }, 500); - - // Set a maximum hold duration (e.g., 1.5 seconds) - maxHoldTimeout.current = setTimeout(() => { - clearTimeout(longPressTimeout.current); - }, 1500); - }; +const normalizeResourceProperties = ( + properties: Record | null | undefined +): ResourceProperties => { + if (!properties || typeof properties !== "object") return {}; - const handleTouchMove = (event) => { + const filename = getValueByKeys(properties, ["filename", "fileName"]); + const mimeType = getValueByKeys(properties, [ + "mimeType", + "mime", + "contentType", + "mediaType", + ]); + const rawSize = getValueByKeys(properties, [ + "sizeInBytes", + "size", + "dataSize", + "createdSize", + "totalSize", + ]); + const qortalName = getValueByKeys(properties, [ + "name", + "qortalName", + "ownerName", + ]); + const title = getValueByKeys(properties, ["title"]); + const encryptionType = getValueByKeys(properties, [ + "encryptionType", + "encryption", + ]); + const parsedSize = Number(rawSize); + const sizeInBytes = + Number.isFinite(parsedSize) && parsedSize >= 0 ? parsedSize : undefined; - const { clientX, clientY } = event.touches[0]; - const { x, y } = startTouchPosition.current; + return { + ...(filename ? { filename } : {}), + ...(filename ? { displayName: filename } : {}), + ...(mimeType ? { mimeType } : {}), + ...(sizeInBytes !== undefined ? { sizeInBytes } : {}), + ...(qortalName ? { qortalName } : {}), + ...(title ? { title } : {}), + ...(encryptionType ? { encryptionType } : {}), + }; +}; - // Determine if the touch has moved beyond a small threshold (e.g., 10px) - const movedEnough = Math.abs(clientX - x) > 10 || Math.abs(clientY - y) > 10; +const buildResourcePropertyPayloads = (item: FileNode) => { + const basePayload = { + action: "GET_QDN_RESOURCE_PROPERTIES", + service: item?.service, + identifier: item?.identifier, + }; + const ownerName = item?.qortalName || item?.name; + if (!ownerName) { + return [basePayload]; + } + return [ + { ...basePayload, name: ownerName }, + { ...basePayload, qortalName: ownerName }, + basePayload, + ]; +}; - if (movedEnough) { - clearTimeout(longPressTimeout.current); - clearTimeout(maxHoldTimeout.current); - } - }; +const clearTimer = ( + timerRef: React.MutableRefObject | null> +) => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } +}; - const handleTouchEnd = (event) => { +export const ContextMenuPinnedFiles = ({ + children, + removeFile, + removeDirectory, + type, + rename, + fileSystem = [], + moveNode, + currentPath, + item, + onPreview, + onCopyEmbedLink, + onHydrateMetadata, + pinned, + onTogglePin, +}: ContextMenuPinnedFilesProps) => { + const [menuPosition, setMenuPosition] = useState(null); + const longPressTimeout = useRef | null>(null); + const maxHoldTimeout = useRef | null>(null); + const preventClick = useRef(false); + const [showMoveModal, setShowMoveModal] = useState(false); + const [showInfoModal, setShowInfoModal] = useState(false); + const [resourceProperties, setResourceProperties] = + useState(null); + const [resourcePropertiesError, setResourcePropertiesError] = useState(""); + const [isFetchingResourceProperties, setIsFetchingResourceProperties] = + useState(false); + const [targetPath, setTargetPath] = useState([]); + const startTouchPosition = useRef({ x: 0, y: 0 }); // Track initial touch position + const handleContextMenu = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + preventClick.current = true; + setMenuPosition({ + mouseX: event.clientX, + mouseY: event.clientY, + }); + }; - clearTimeout(longPressTimeout.current); - clearTimeout(maxHoldTimeout.current); - if (preventClick.current) { - event.preventDefault(); - event.stopPropagation(); - preventClick.current = false; - } - }; + const handleTouchStart = (event: React.TouchEvent) => { + const { clientX, clientY } = event.touches[0]; + startTouchPosition.current = { x: clientX, y: clientY }; - const handleClose = (e) => { + longPressTimeout.current = setTimeout(() => { + preventClick.current = true; - e.preventDefault(); - e.stopPropagation(); - setMenuPosition(null); - }; + event.stopPropagation(); + setMenuPosition({ + mouseX: clientX, + mouseY: clientY, + }); + }, 500); + + // Set a maximum hold duration (e.g., 1.5 seconds) + maxHoldTimeout.current = setTimeout(() => { + clearTimer(longPressTimeout); + }, 1500); + }; - const renderDirectoryTree = (directories, currentPathParam = []) => { - return directories.filter((fd)=> fd?.type === 'folder').map((dir) => { - // Construct the fullPath by including the current directory or file name - const fullPath = [...currentPathParam, dir.name]; - const currentFullPath = [...currentPathParam, item.name]; + const handleTouchMove = (event: React.TouchEvent) => { + const { clientX, clientY } = event.touches[0]; + const { x, y } = startTouchPosition.current; - // Determine if the current item is the selected one - const isSelected = fullPath.join("/") === targetPath.join("/"); - const isCurrentDir = fullPath.join("/") === currentPath.join("/"); - const isHoveredDir = fullPath.join("/") === currentFullPath.join("/"); + // Determine if the touch has moved beyond a small threshold (e.g., 10px) + const movedEnough = + Math.abs(clientX - x) > 10 || Math.abs(clientY - y) > 10; + + if (movedEnough) { + clearTimer(longPressTimeout); + clearTimer(maxHoldTimeout); + } + }; + + const handleTouchEnd = (event: React.TouchEvent) => { + clearTimer(longPressTimeout); + clearTimer(maxHoldTimeout); + if (preventClick.current) { + event.preventDefault(); + event.stopPropagation(); + preventClick.current = false; + } + }; + + const handleClose = (e) => { + if (e?.preventDefault) e.preventDefault(); + if (e?.stopPropagation) e.stopPropagation(); + setMenuPosition(null); + }; + + const renderDirectoryTree = ( + directories: FileNode[] = [], + currentPathParam: string[] = [] + ) => { + return directories + .filter((fd) => fd?.type === "folder") + .map((dir) => { + // Construct the fullPath by including the current directory or file name + const fullPath = [...currentPathParam, dir.name || ""]; + const currentFullPath = [...currentPathParam, item?.name || ""]; + + // Determine if the current item is the selected one + const isSelected = fullPath.join("/") === targetPath.join("/"); + const isCurrentDir = fullPath.join("/") === currentPath.join("/"); + const isHoveredDir = fullPath.join("/") === currentFullPath.join("/"); // const isItSelf = dir?.type === 'folder' && dir.name === - if(dir.type !== "folder" ) return null - - return ( - - {/* Render the current directory or file */} - - { - if(isCurrentDir || isHoveredDir) return - setTargetPath(fullPath) - }} - sx={{ - backgroundColor: (isCurrentDir || isHoveredDir) ? 'inherit' : isSelected ? "#1976d2" : "inherit", - color: (isCurrentDir || isHoveredDir) ? 'inherit' : isSelected ? "#ffffff" : "inherit", - "&:hover": { - backgroundColor: (isCurrentDir || isHoveredDir) ? 'inherit' : "#1976d2", - color: (isCurrentDir || isHoveredDir) ? 'inherit' : "#ffffff" - }, - cursor: (isCurrentDir || isHoveredDir) ? 'default' : 'pointer' - }} - > - {dir.type === "folder" && ( - <> - - - - - - - - )} - - - - {/* Recursively render children if it's a folder */} - {dir.type === "folder" && dir.children && dir.children.length > 0 && ( + if (dir.type !== "folder") return null; + + return ( + + {/* Render the current directory or file */} + + { + if (isCurrentDir || isHoveredDir) return; + setTargetPath(fullPath); + }} + sx={{ + backgroundColor: + isCurrentDir || isHoveredDir + ? "inherit" + : isSelected + ? "#1976d2" + : "inherit", + color: + isCurrentDir || isHoveredDir + ? "inherit" + : isSelected + ? "#ffffff" + : "inherit", + "&:hover": { + backgroundColor: + isCurrentDir || isHoveredDir ? "inherit" : "#1976d2", + color: isCurrentDir || isHoveredDir ? "inherit" : "#ffffff", + }, + cursor: isCurrentDir || isHoveredDir ? "default" : "pointer", + }} + > + {dir.type === "folder" && ( + <> + + + + + + )} + + + {/* Recursively render children if it's a folder */} + {dir.type === "folder" && + dir.children && + dir.children.length > 0 && ( {renderDirectoryTree(dir.children, fullPath)} )} - - ); - }); - }; - - - - - - - - - - - - - const openMoveModal = () => { - setShowMoveModal(true); - setMenuPosition(null); // Close the context menu - }; - - const closeMoveModal = () => { - setShowMoveModal(false); - }; - - const handleMove = () => { - if (targetPath.length > 0) { - moveNode("name", "type", ["current", "path"], targetPath); // Replace with your logic - closeMoveModal(); + + ); + }); + }; + + const openMoveModal = () => { + setShowMoveModal(true); + setMenuPosition(null); // Close the context menu + }; + + const closeMoveModal = () => { + setShowMoveModal(false); + }; + const closeInfoModal = () => { + setShowInfoModal(false); + }; + + const hasKnownFileMetadata = useMemo(() => { + if (type !== "file") return true; + const existingSize = getValueByKeys(item, [ + "sizeInBytes", + "size", + "fileSize", + "dataSize", + "createdSize", + "totalSize", + ]); + return Boolean(item?.mimeType) && existingSize !== undefined; + }, [ + item?.mimeType, + item?.sizeInBytes, + item?.size, + item?.fileSize, + item?.dataSize, + item?.createdSize, + item?.totalSize, + type, + ]); + + const mergedItemInfo = useMemo>(() => { + const { key, publicKey, sharingKey, ...safeItem } = item || {}; + if (!resourceProperties) return safeItem; + const { + key: resourceKey, + publicKey: resourcePublicKey, + sharingKey: resourceSharingKey, + ...safeResourceProperties + } = resourceProperties || {}; + void resourceKey; + void resourcePublicKey; + void resourceSharingKey; + return { + ...safeItem, + fetchedResourceProperties: safeResourceProperties, + }; + }, [item, resourceProperties]); + + const fetchResourceProperties = async () => { + if (type !== "file" || !item?.service || !item?.identifier) { + return; + } + + setIsFetchingResourceProperties(true); + setResourcePropertiesError(""); + + const payloadAttempts = buildResourcePropertyPayloads(item); + let lastError: any = null; + const encryptedResource = isEncryptedResourceNode(item); + + for (const payload of payloadAttempts) { + try { + const response = await requestQortal(payload); + if (response === undefined || response === null) { + continue; } - }; - - return ( -
- {children} - { - e.stopPropagation(); - }} - > - {type === 'file' && ( - { - handleClose(e); - removeFile() - }}> - - - - - remove file - - - )} - {type === 'folder' && ( - { - handleClose(e); - removeDirectory() - }}> - - - - - remove directory - - - - )} - { - handleClose(e); - rename() - }}> - - - - - rename - - - - openMoveModal() - } + + setResourceProperties(response); + const normalized = normalizeResourceProperties(response); + const metadataToHydrate: Record = encryptedResource + ? { + ...(normalized?.mimeType + ? { mimeType: normalized.mimeType } + : {}), + ...(normalized?.title ? { title: normalized.title } : {}), + ...(normalized?.encryptionType + ? { encryptionType: normalized.encryptionType } + : {}), + ...((response as any)?.publicKey + ? { publicKey: (response as any).publicKey } + : {}), + } + : { ...normalized }; + if ( + !encryptedResource && + normalized?.filename && + (!item?.displayName || + item?.displayName === item?.name || + item?.displayName === item?.identifier) + ) { + metadataToHydrate.displayName = normalized.filename; + } + if ( + encryptedResource && + !metadataToHydrate?.displayName && + item?.name + ) { + metadataToHydrate.displayName = item.name; + } + if ( + onHydrateMetadata && + typeof onHydrateMetadata === "function" && + Object.keys(metadataToHydrate).length > 0 + ) { + onHydrateMetadata(metadataToHydrate); + } + setIsFetchingResourceProperties(false); + return; + } catch (error) { + lastError = error; + } + } + + setResourcePropertiesError( + lastError?.error || + lastError?.message || + "Unable to fetch live QDN properties" + ); + setIsFetchingResourceProperties(false); + }; + + useEffect(() => { + setResourceProperties(null); + setResourcePropertiesError(""); + setIsFetchingResourceProperties(false); + }, [item?.identifier, item?.service, item?.qortalName, item?.name]); + + useEffect(() => { + if (!showInfoModal) return; + if (type !== "file") return; + if (isFetchingResourceProperties) return; + if (resourceProperties) return; + if (hasKnownFileMetadata) return; + fetchResourceProperties(); + }, [ + showInfoModal, + type, + isFetchingResourceProperties, + resourceProperties, + hasKnownFileMetadata, + ]); + + const handleMove = () => { + if (targetPath.length > 0) { + moveNode("name", "type", ["current", "path"], targetPath); // Replace with your logic + closeMoveModal(); + } + }; + + return ( +
+ {children} + { + e.stopPropagation(); + }} + > + {type === "file" && !!onPreview && ( + { + handleClose(e); + onPreview(); + }} + > + + + + + preview + + + )} + {type === "file" && !!onCopyEmbedLink && ( + { + handleClose(e); + onCopyEmbedLink(); + }} + > + + + + + copy embed link + + + )} + {type === "file" && ( + { + handleClose(e); + onTogglePin?.(); + }} + > + + + + + {pinned ? "unpin file" : "pin file"} + + + )} + {type === "file" && ( + { + handleClose(e); + removeFile(); + }} + > + + + + + remove file + + + )} + {type === "folder" && ( + { + handleClose(e); + removeDirectory(); + }} + > + + + + + remove directory + + + )} + { + handleClose(e); + rename(); + }} > + + + + + rename + + + openMoveModal()}> @@ -255,47 +624,127 @@ moveNode, currentPath, item }) => { Move - + { + handleClose(e); + setShowInfoModal(true); + }} + > + + + + + More info + + + - + Select Target Folder {renderDirectoryTree(fileSystem)} - - + targetPath // Pass the selected targetPath + ); + }} + > + Move Here + -
- ); + + + Item details + + Showing all known metadata for this item. + + {type === "file" && ( + + + {resourcePropertiesError && ( + + {resourcePropertiesError} + + )} + + )} + + {JSON.stringify(mergedItemInfo, null, 2)} + + + + + + +
+ ); }; diff --git a/src/File.tsx b/src/File.tsx index 0d70d54..9f32c40 100644 --- a/src/File.tsx +++ b/src/File.tsx @@ -18,26 +18,122 @@ import { } from "@mui/material"; import { styled } from "@mui/system"; import { Transition } from "./ShowAction"; +import type { TransitionProps } from "@mui/material/transitions/transition"; import CloseIcon from "@mui/icons-material/Close"; import { Label, PUBLISH_QDN_RESOURCE } from "./actions/PUBLISH_QDN_RESOURCE"; -import { base64ToUint8Array, uint8ArrayToObject } from "./utils"; +import { + isPrivateGroupQManagerIdentifier, +} from "./utils"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import ExpandLessIcon from "@mui/icons-material/ExpandLess"; import { Spacer } from "./components/Spacer"; import WarningIcon from "@mui/icons-material/Warning"; import { openToast } from "./components/openToast"; +import { requestQortal } from "./qapp/request"; +import { copyEmbedLinkForFile } from "./embedLink"; + +const isEncryptedFileNode = (file) => { + const service = + typeof file?.service === "string" ? file.service.toUpperCase() : ""; + const encryptionType = + typeof file?.encryptionType === "string" + ? file.encryptionType.toLowerCase() + : ""; + const identifier = + typeof file?.identifier === "string" ? file.identifier.toLowerCase() : ""; + + return ( + encryptionType.includes("private") || + isPrivateGroupQManagerIdentifier(identifier) || + service.includes("_PRIVATE") || + identifier.startsWith("p-") || + identifier.startsWith("pvt-") + ); +}; + +const isGenericPrivateResourceLabel = (value) => { + const normalized = + typeof value === "string" ? value.trim().toLowerCase() : ""; + if (!normalized) return true; + return ( + normalized === "data" || + normalized === "file" || + normalized === "blob" || + normalized === "resource" || + normalized === "preview" || + normalized === "unknown" || + normalized === "untitled" || + normalized === "data.bin" || + normalized.startsWith("data.") + ); +}; + +const getDisplayName = (file) => + isEncryptedFileNode(file) + ? (() => { + const name = typeof file?.name === "string" ? file.name : ""; + const displayName = + typeof file?.displayName === "string" ? file.displayName : ""; + if (displayName && !isGenericPrivateResourceLabel(displayName)) { + return displayName; + } + if (name && !isGenericPrivateResourceLabel(name)) { + return name; + } + return displayName || name || ""; + })() + : file?.displayName || file?.name || ""; + +const getFileSizeBytes = (file) => { + const candidates = [ + file?.sizeInBytes, + file?.size, + file?.fileSize, + file?.dataSize, + file?.createdSize, + file?.totalSize, + ]; + for (const candidate of candidates) { + const parsed = Number(candidate); + if (Number.isFinite(parsed) && parsed >= 0) return parsed; + } + return null; +}; + +const formatBytes = (value) => { + const bytes = Number(value); + if (!Number.isFinite(bytes) || bytes < 0) return "Unknown"; + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB", "TB"]; + let size = bytes; + let unitIndex = -1; + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + if (unitIndex < 0) return `${bytes} B`; + return `${size >= 10 ? size.toFixed(1) : size.toFixed(2)} ${units[unitIndex]}`; +}; export const SelectedFile = ({ selectedFile, setSelectedFile, updateByPath, + myName, + accountAddress, + accountPublicKey, mode, groups, - selectedGroup + selectedGroup, + addNodeByPath, }) => { - const [selectedType, setSelectedType] = useState(0); + const [selectedType, setSelectedType] = useState(0); const [isExpandMore, setIsExpandMore] = useState(false); - const [customFileName, setCustomFileName] = useState(selectedFile?.name) + const [customFileName, setCustomFileName] = useState( + getDisplayName(selectedFile) + ); + const fileSizeBytes = getFileSizeBytes(selectedFile); + const isEncryptedSelectedFile = isEncryptedFileNode(selectedFile); useEffect(() => { if (selectedFile?.mimeType?.toLowerCase()?.includes("image")) { setSelectedType("IMAGE"); @@ -45,63 +141,31 @@ export const SelectedFile = ({ setSelectedType("ATTACHMENT"); } }, [selectedFile?.mimeType]); + useEffect(() => { + setCustomFileName(getDisplayName(selectedFile)); + }, [ + selectedFile?.identifier, + selectedFile?.service, + selectedFile?.displayName, + selectedFile?.name, + selectedFile?.filename, + ]); const createEmbedLink = async () => { - - const promise = (async ()=> { + const promise = (async () => { try { - if (mode === "public") { - await qortalRequest({ - action: "CREATE_AND_COPY_EMBED_LINK", - type: selectedType, - name: selectedFile.qortalName, - identifier: selectedFile.identifier, - service: selectedFile.service, - mimeType: selectedFile?.mimeType, - fileName: customFileName - }); - return; - } - if (mode === "group") { - await qortalRequest({ - action: "CREATE_AND_COPY_EMBED_LINK", - type: selectedType, - name: selectedFile.qortalName, - identifier: selectedFile.identifier, - service: selectedFile.service, - mimeType: selectedFile?.mimeType, - fileName: customFileName, - encryptionType: 'group', - }); - return; - } - const res = await fetch( - `/arbitrary/${selectedFile.service}/${selectedFile.qortalName}/${selectedFile.identifier}?encoding=base64` - ); - const base64Data = await res.text(); - const decryptedData = await qortalRequest({ - action: "DECRYPT_DATA", - encryptedData: base64Data, - }); - const decryptToUnit8Array = base64ToUint8Array(decryptedData); - const responseData = uint8ArrayToObject(decryptToUnit8Array); - if (!responseData?.key) - throw new Error("Could not find key in encrypted data"); - await qortalRequest({ - action: "CREATE_AND_COPY_EMBED_LINK", - type: selectedType, - name: selectedFile.qortalName, - identifier: selectedFile.identifier, - service: selectedFile.service, - encryptionType: 'private', - key: responseData.key, - mimeType: selectedFile?.mimeType, - fileName: customFileName + await copyEmbedLinkForFile({ + file: selectedFile, + requestQortal, + selectedType, + customFileName, + accountAddress, + accountPublicKey, }); - return true + return true; } catch (error) { - throw error + throw error; } - })() + })(); await openToast(promise, { loading: "Downloading resource and fetching link... please wait.", success: "Copied successfully!", @@ -114,7 +178,11 @@ export const SelectedFile = ({ fullScreen open={!!selectedFile} onClose={() => setSelectedFile(null)} - TransitionComponent={Transition} + TransitionComponent={ + Transition as React.ComponentType< + TransitionProps & { children: React.ReactElement } + > + } PaperProps={{ style: { backgroundColor: "rgb(39, 40, 44)", @@ -127,7 +195,7 @@ export const SelectedFile = ({ > - {selectedFile?.name} + {getDisplayName(selectedFile)} ATTACHMENT - + + + + ); +}; const SortableItem = ({ item, onClick, + onSelect, removeFile, removeDirectory, rename, fileSystem, moveNode, currentPath, + selected, + onPreview, + showThumbnails, + showPrivateThumbnails, + onHydrateMetadata, + onTogglePin, + accountAddress, + accountPublicKey, + privateThumbnailAttemptedRef, }) => { + const sortableId = getNodeSelectionKey(item); const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ - id: item.name + item.type, + id: sortableId, }); + const clickTimeoutRef = useRef(null); + const [thumbnailError, setThumbnailError] = useState(false); + const [decryptedThumbnailUrl, setDecryptedThumbnailUrl] = useState(""); + const [thumbnailLoading, setThumbnailLoading] = useState(false); + const previewKind = inferPreviewKind(item); + const encrypted = isEncryptedResource(item); + const shouldShowThumbnail = + item?.type === "file" && + (encrypted ? showPrivateThumbnails : showThumbnails); + const thumbnailUrl = + shouldShowThumbnail && !encrypted && previewKind === "image" + ? getResourcePreviewUrl(item) + : ""; + const privateThumbnailMimeType = + item?.thumbnailMimeType || item?.mimeType || "image/jpeg"; + const cachedPrivateThumbnailUrl = + shouldShowThumbnail && encrypted && item?.thumbnailData64 + ? `data:${privateThumbnailMimeType};base64,${item.thumbnailData64}` + : ""; + + useEffect(() => { + setThumbnailError(false); + }, [ + item?.identifier, + item?.service, + item?.qortalName, + item?.thumbnailData64, + item?.thumbnailMimeType, + accountAddress, + accountPublicKey, + showThumbnails, + showPrivateThumbnails, + ]); + + useEffect(() => { + setDecryptedThumbnailUrl(""); + setThumbnailLoading(false); + + if (!shouldShowThumbnail) return; + if (!encrypted) return; + if (cachedPrivateThumbnailUrl) return; + + const attemptedSet = privateThumbnailAttemptedRef?.current; + const cacheKey = [ + getFileIdentity(item), + accountPublicKey || "", + ].join("|"); + if (attemptedSet?.has(cacheKey)) return; + attemptedSet?.add(cacheKey); + + const controller = new AbortController(); + let disposed = false; + + const loadThumbnail = async () => { + setThumbnailLoading(true); + try { + const payload = await fetchPreviewPayload( + item, + controller.signal, + accountAddress, + accountPublicKey, + { cacheThumbnail: false } + ); + if (disposed) return; + const thumbnailMimeType = + payload?.metadata?.mimeType || + item?.mimeType || + inferMimeTypeFromExtension(getFileExtension(item)) || + inferMimeTypeFromBase64(payload.data64); + if (inferPreviewKindFromMimeType(thumbnailMimeType) !== "image") { + return; + } + const thumbnail = await createImageThumbnailData64( + payload.data64, + thumbnailMimeType || "image/png", + { + maxWidth: 160, + maxHeight: 160, + outputMimeType: "image/jpeg", + quality: 0.82, + } + ); + if (disposed || controller.signal.aborted) return; + if (!thumbnail?.data64) return; + await upsertPrivateResourceIndexEntry(accountAddress, { + resourceKey: getFileIdentity(item), + qortalName: item?.qortalName || "", + service: getServiceName(item), + identifier: item?.identifier, + thumbnailData64: thumbnail.data64, + thumbnailMimeType: thumbnail.mimeType || "image/jpeg", + }); + setDecryptedThumbnailUrl( + `data:${thumbnail.mimeType || "image/jpeg"};base64,${thumbnail.data64}` + ); + } catch (error) { + if (!controller.signal.aborted && !disposed) { + setThumbnailError(true); + } + } finally { + if (!disposed) { + setThumbnailLoading(false); + } + } + }; + + loadThumbnail(); + + return () => { + disposed = true; + controller.abort(); + }; + }, [ + item?.identifier, + item?.service, + item?.qortalName, + item?.mimeType, + item?.thumbnailData64, + shouldShowThumbnail, + encrypted, + accountAddress, + accountPublicKey, + cachedPrivateThumbnailUrl, + privateThumbnailAttemptedRef, + ]); + + const effectiveThumbnailUrl = encrypted + ? cachedPrivateThumbnailUrl || decryptedThumbnailUrl + : thumbnailUrl; + + useEffect(() => { + return () => { + if (clickTimeoutRef.current) { + clearTimeout(clickTimeoutRef.current); + clickTimeoutRef.current = null; + } + }; + }, []); const style = { transform: CSS.Transform.toString(transform), transition, - padding: "10px", - marginBottom: "5px", - borderRadius: "4px", + padding: "12px", + marginBottom: "10px", + borderRadius: "12px", cursor: "grab", }; + const handleCopyEmbedLink = async () => { + const promise = copyEmbedLinkForFile({ + file: item, + requestQortal, + accountAddress, + accountPublicKey, + }); + await openToast(promise, { + loading: "Copying embed link...", + success: "Copied successfully!", + error: (err) => `Failed to copy: ${err.error || err.message || err}`, + }); + }; return ( { + if (item?.type !== "file") { + onClick?.(event); + return; + } + if (clickTimeoutRef.current) { + clearTimeout(clickTimeoutRef.current); + } + if (event?.shiftKey || event?.metaKey || event?.ctrlKey) { + onSelect?.(event); + return; + } + clickTimeoutRef.current = setTimeout(() => { + onClick?.(event); + clickTimeoutRef.current = null; + }, 220); + }} + onDoubleClick={() => { + if (item?.type !== "file") { + return; + } + if (clickTimeoutRef.current) { + clearTimeout(clickTimeoutRef.current); + clickTimeoutRef.current = null; + } + onPreview?.(); }} - onClick={() => onClick()} > + {item?.type === "file" && ( + { + event.stopPropagation(); + }} + onMouseDown={(event) => { + event.stopPropagation(); + }} + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + onSelect?.(event); + }} + sx={{ + position: "absolute", + top: "2px", + left: "2px", + zIndex: 2, + p: "2px", + color: "#93b8e7", + "&.Mui-checked": { color: "#59b2ff" }, + backgroundColor: "rgba(15, 17, 22, 0.42)", + borderRadius: "6px", + }} + /> + )} + {item?.type === "file" && Boolean(item?.pinned) && ( + + )} {item.type === "folder" ? ( - + + ) : effectiveThumbnailUrl && !thumbnailError ? ( + setThumbnailError(true)} + sx={{ + width: "100%", + height: "100%", + objectFit: "cover", + borderRadius: "inherit", + }} + /> ) : ( - + )} - {item.name} + {getItemDisplayName(item)} @@ -151,39 +2339,163 @@ const SortableItem = ({ ); }; -export const Manager = ({ myAddress, groups }) => { +export const Manager = ({ + myAddress, + groups, + ownedNames = [], + activeName = "", + onChangeActiveName, +}) => { const [fileSystemPublic, setFileSystemPublic] = useState(null); const [fileSystemPrivate, setFileSystemPrivate] = useState(null); - const [fileSystemGroup, setFileSystemGroup] = useState(null); - const [selectedGroup, setSelectedGroup] = useState(null); + const [fileSystemGroup, setFileSystemGroup] = useState( + initialGroupFileSystem + ); + const [selectedGroup, setSelectedGroup] = useState(() => + getEmbeddedGroupIdHint(myAddress) + ); + const publishNames = Array.isArray(myAddress?.names) + ? myAddress.names.filter((item) => item?.name) + : myAddress?.name?.name + ? [myAddress.name] + : []; + const [activePublishName, setActivePublishName] = useState( + myAddress?.name?.name || "" + ); + + const [mode, setMode] = useState("public"); + const [privateIndexRevision, setPrivateIndexRevision] = useState(0); + const [privateResourceIndex, setPrivateResourceIndex] = useState(null); + const fileSystemSnapshotRef = useRef(null); + + useEffect(() => { + if (typeof window === "undefined") return undefined; + + const handlePrivateIndexChanged = () => { + setPrivateIndexRevision((prev) => prev + 1); + }; + + window.addEventListener( + "q-manager-private-index-changed", + handlePrivateIndexChanged + ); + + return () => { + window.removeEventListener( + "q-manager-private-index-changed", + handlePrivateIndexChanged + ); + }; + }, []); + + useEffect(() => { + if (!myAddress?.address) { + setPrivateResourceIndex(null); + return undefined; + } + + let disposed = false; + const loadPrivateResourceIndex = async () => { + try { + const loadedIndex = await getPersistedPrivateResourceIndex( + myAddress.address, + [myAddress?.name?.name, activePublishName].filter(Boolean) + ); + if (!disposed) { + setPrivateResourceIndex(loadedIndex); + } + } catch (error) { + if (!disposed) { + setPrivateResourceIndex(null); + } + } + }; + + loadPrivateResourceIndex(); -const [mode, setMode] = useState('public') + return () => { + disposed = true; + }; + }, [ + myAddress?.address, + myAddress?.name?.name, + activePublishName, + privateIndexRevision, + ]); -const [fileSystem, setFileSystem] = useMemo(() => { - if (mode === 'public') { + const [fileSystem, setFileSystem] = useMemo(() => { + if (mode === "public") { return [fileSystemPublic, setFileSystemPublic]; - } else if (mode === 'group') { + } else if (mode === "group") { if (selectedGroup) { - const selectedGroupState = fileSystemGroup[selectedGroup] || initialFileSystem; - const setSelectedGroupState = (newState) => { - setFileSystemGroup((prev) => ({ - ...(prev || {}), - [selectedGroup]: newState, - })); - }; - return [selectedGroupState, setSelectedGroupState]; + const selectedGroupState = + fileSystemGroup[selectedGroup] || initialFileSystem; + const setSelectedGroupState = (newState) => { + setFileSystemGroup((prev) => ({ + ...(prev || {}), + [selectedGroup]: newState, + })); + }; + return [selectedGroupState, setSelectedGroupState]; } return [fileSystemGroup, setFileSystemGroup]; - } else { + } else { return [fileSystemPrivate, setFileSystemPrivate]; - } -}, [mode, fileSystemPublic, fileSystemPrivate, fileSystemGroup, selectedGroup]); + } + }, [ + mode, + fileSystemPublic, + fileSystemPrivate, + fileSystemGroup, + selectedGroup, + ]); + + useEffect(() => { + fileSystemSnapshotRef.current = fileSystem; + }, [fileSystem]); const { isShow, onCancel, onOk, show, type } = useModal(); const [newDirName, setNewDirName] = useState(""); const [newName, setNewName] = useState(""); + const newDirInputRef = useRef(null); const [selectedFile, setSelectedFile] = useState(null); + const [previewFile, setPreviewFile] = useState(null); + const [selectedFileKeys, setSelectedFileKeys] = useState([]); + const selectionAnchorKeyRef = useRef(""); + const privateThumbnailAttemptedRef = useRef(new Set()); + const [showThumbnails, setShowThumbnails] = useState(() => { + try { + return localStorage.getItem(SHOW_THUMBNAILS_KEY) === "1"; + } catch (error) { + return false; + } + }); + const [showPrivateThumbnails, setShowPrivateThumbnails] = useState(() => { + try { + return localStorage.getItem(SHOW_PRIVATE_THUMBNAILS_KEY) === "1"; + } catch (error) { + return false; + } + }); + const [autoQdnFileSystemSync, setAutoQdnFileSystemSync] = useState(() => { + try { + return localStorage.getItem(AUTO_QDN_FILESYSTEM_SYNC_KEY) !== "0"; + } catch (error) { + return true; + } + }); + const [qdnFileSystemLoadReady, setQdnFileSystemLoadReady] = useState(false); + const [showBulkMoveModal, setShowBulkMoveModal] = useState(false); + const [bulkMoveTargetPath, setBulkMoveTargetPath] = useState([]); + const [qdnSyncPrompt, setQdnSyncPrompt] = useState(null); + const [qdnBackupDirty, setQdnBackupDirty] = useState(false); + const fileSystemLoadedRef = useRef(false); + const skipNextQdnPublishPromptRef = useRef(true); + const qdnPublishPromptRef = useRef(null); + const lastQdnSyncedSnapshotRef = useRef(""); + const dismissedPublishSnapshotRef = useRef(""); + const checkedQdnLoadRef = useRef(false); const [currentPath, setCurrentPath] = useState(["Root"]); const [isOpenPublish, setIsOpenPublish] = useState(false); @@ -198,50 +2510,382 @@ const [fileSystem, setFileSystem] = useMemo(() => { return folder; }, [currentPath, fileSystem]); - useEffect(()=> { - if(!selectedGroup && groups?.length > 0){ - setSelectedGroup(groups[0]?.groupId) + const groupOptions = useMemo(() => { + if (!Array.isArray(groups)) return []; + + return groups + .map((group) => { + const normalizedGroupId = normalizeGroupId(group?.groupId); + const fileCount = normalizedGroupId + ? countFileNodesInTree(fileSystemGroup?.[normalizedGroupId]) + : 0; + + return { + ...group, + normalizedGroupId, + fileCount, + }; + }) + .filter((group) => group.normalizedGroupId !== null) + .sort((a, b) => { + const countDelta = Number(b.fileCount || 0) - Number(a.fileCount || 0); + if (countDelta !== 0) return countDelta; + + const nameDelta = String(a.groupName || `Group ${a.normalizedGroupId}`).localeCompare( + String(b.groupName || `Group ${b.normalizedGroupId}`), + undefined, + { + sensitivity: "base", + } + ); + if (nameDelta !== 0) return nameDelta; + + return Number(a.normalizedGroupId || 0) - Number(b.normalizedGroupId || 0); + }); + }, [groups, fileSystemGroup]); + + const preferredGroupId = useMemo(() => { + if (!groupOptions.length) { + return getEmbeddedGroupIdHint(myAddress); + } + + const hintedGroupId = getEmbeddedGroupIdHint(myAddress); + if ( + hintedGroupId && + groupOptions.some( + (groupOption) => + Number(groupOption.normalizedGroupId) === Number(hintedGroupId) + ) + ) { + return hintedGroupId; + } + + return groupOptions[0]?.normalizedGroupId || hintedGroupId || null; + }, [groupOptions, myAddress]); + + useEffect(() => { + if (mode !== "group") return; + if (!groupOptions.length) return; + + const normalizedSelectedGroup = normalizeGroupId(selectedGroup); + const selectedGroupExists = + normalizedSelectedGroup !== null && + groupOptions.some( + (groupOption) => + Number(groupOption.normalizedGroupId) === Number(normalizedSelectedGroup) + ); + + if (selectedGroupExists) return; + + const nextGroupId = preferredGroupId || groupOptions[0]?.normalizedGroupId || null; + if (nextGroupId) { + setSelectedGroup(nextGroupId); + } + }, [mode, groupOptions, preferredGroupId, selectedGroup]); + + useEffect(() => { + if (!activePublishName && myAddress?.name?.name) { + setActivePublishName(myAddress.name.name); + } + }, [activePublishName, myAddress?.name?.name]); + + useEffect(() => { + if (isShow && type === "new-directory") { + const timer = setTimeout(() => { + newDirInputRef.current?.focus?.(); + newDirInputRef.current?.select?.(); + }, 0); + return () => clearTimeout(timer); } - }, [groups]) + return undefined; + }, [isShow, type]); + + useEffect(() => { + setSelectedFileKeys([]); + }, [mode, selectedGroup]); + + useEffect(() => { + try { + localStorage.setItem(SHOW_THUMBNAILS_KEY, showThumbnails ? "1" : "0"); + } catch (error) {} + }, [showThumbnails]); + + useEffect(() => { + try { + localStorage.setItem( + SHOW_PRIVATE_THUMBNAILS_KEY, + showPrivateThumbnails ? "1" : "0" + ); + } catch (error) {} + }, [showPrivateThumbnails]); + + useEffect(() => { + try { + localStorage.setItem( + AUTO_QDN_FILESYSTEM_SYNC_KEY, + autoQdnFileSystemSync ? "1" : "0" + ); + } catch (error) {} + }, [autoQdnFileSystemSync]); + + const showPublishNotice = (prompt) => { + qdnPublishPromptRef.current = prompt; + setQdnBackupDirty(true); + }; + + const openPublishPrompt = () => { + if (!qdnPublishPromptRef.current) return; + setQdnSyncPrompt(qdnPublishPromptRef.current); + }; + + const clearPublishNotice = () => { + qdnPublishPromptRef.current = null; + setQdnBackupDirty(false); + }; const handleNavigate = (folderName) => { + setSelectedFileKeys([]); setCurrentPath((prev) => [...prev, folderName]); }; const handleBack = () => { if (currentPath.length > 1) { + setSelectedFileKeys([]); setCurrentPath((prev) => prev.slice(0, -1)); } }; useEffect(() => { - if(!myAddress?.address) return + if (!myAddress?.address) return; const fetchFileSystem = async () => { - const data = await getFileSystemQManagerFromDB(myAddress?.address); - if (data?.private && data?.public){ - setFileSystemPublic(data?.public) - setFileSystemPrivate(data?.private) - setFileSystemGroup(data?.group || initialFileSystem) + checkedQdnLoadRef.current = false; + setQdnFileSystemLoadReady(false); + setQdnSyncPrompt(null); + setQdnBackupDirty(false); + qdnPublishPromptRef.current = null; + const data = await getPersistedFileSystemQManager(myAddress?.address); + const currentPrivateResourceIndex = await getPersistedPrivateResourceIndex( + myAddress?.address, + [myAddress?.name?.name, activePublishName].filter(Boolean) + ).catch(() => null); + const loadedPayload = + data?.private && data?.public + ? { + public: data.public, + private: data.private, + group: + data?.group && !Array.isArray(data.group) + ? data.group + : initialGroupFileSystem, + } + : { + public: initialFileSystem, + private: initialFileSystem, + group: initialGroupFileSystem, + }; + if (currentPrivateResourceIndex) { + loadedPayload.privateResourceIndex = currentPrivateResourceIndex; + } + lastQdnSyncedSnapshotRef.current = stableStringify( + normalizeQdnSyncPayloadForComparison(loadedPayload) + ); + setPrivateResourceIndex(currentPrivateResourceIndex); + if (data?.private && data?.public) { + setFileSystemPublic(data?.public); + setFileSystemPrivate(data?.private); + const groupData = + data?.group && !Array.isArray(data.group) + ? data.group + : initialGroupFileSystem; + setFileSystemGroup(groupData); } else { setFileSystemPublic(initialFileSystem); - setFileSystemPrivate(initialFileSystem) - setFileSystemGroup(initialFileSystem) - + setFileSystemPrivate(initialFileSystem); + setFileSystemGroup(initialGroupFileSystem); } + fileSystemLoadedRef.current = true; + setQdnFileSystemLoadReady(true); }; fetchFileSystem(); }, [myAddress?.address]); useEffect(() => { - if (fileSystemPublic && fileSystemPrivate && myAddress?.address) { - saveFileSystemQManagerToDB({public: fileSystemPublic, private: fileSystemPrivate, group: fileSystemGroup}, myAddress?.address); + const qdnOwnerNameCandidate = activePublishName || myAddress?.name?.name; + if (!qdnOwnerNameCandidate || !qdnFileSystemLoadReady || checkedQdnLoadRef.current) { + return; } - }, [fileSystemPublic , fileSystemPrivate, fileSystemGroup, myAddress?.address]); + let disposed = false; + checkedQdnLoadRef.current = true; + const loadPublishedFileSystem = async () => { + try { + const qdnOwnerName = + (await resolvePreferredName( + activePublishName || myAddress?.name?.name, + myAddress?.address + )) || myAddress?.name?.name; + if (!qdnOwnerName) return; + + const imported = await importFileSystemQManagerFromQDN( + qdnOwnerName + ); + if (disposed || !imported?.public || !imported?.private) return; + + const currentPrivateResourceIndex = + await getPersistedPrivateResourceIndex( + myAddress?.address, + [myAddress?.name?.name, activePublishName].filter(Boolean) + ); + + const importedPayload = { + public: imported.public, + private: imported.private, + group: + imported?.group && !Array.isArray(imported.group) + ? imported.group + : initialGroupFileSystem, + ...(imported?.privateResourceIndex + ? { privateResourceIndex: imported.privateResourceIndex } + : {}), + }; + const currentPayload = { + public: fileSystemPublic, + private: fileSystemPrivate, + group: fileSystemGroup, + ...(currentPrivateResourceIndex + ? { privateResourceIndex: currentPrivateResourceIndex } + : {}), + }; + const localSnapshot = stableStringify( + normalizeQdnSyncPayloadForComparison(currentPayload) + ); + const importedSnapshot = stableStringify( + normalizeQdnSyncPayloadForComparison(importedPayload) + ); + + // If the dismissed snapshot matches local, the user already rejected this QDN state. + // Update the baseline to local and don't show the prompt (prevents deleted files from re-adding). + const dismissedSnapshot = dismissedPublishSnapshotRef.current; + if (dismissedSnapshot && dismissedSnapshot === localSnapshot) { + lastQdnSyncedSnapshotRef.current = localSnapshot; + return; + } + + lastQdnSyncedSnapshotRef.current = importedSnapshot; + if (localSnapshot === importedSnapshot) { + return; + } + + const diff = diffQdnSyncPayload(currentPayload, importedPayload); + setQdnSyncPrompt({ + type: "load", + title: "Load Published Filesystem Backup?", + intro: + "A QDN backup was found that differs from the filesystem currently loaded in Q-Manager. This includes both the filesystem structure and the private resource index.", + fromLabel: "Current local", + toLabel: "Published QDN backup", + fromSummary: summarizeQdnSyncPayload(currentPayload), + toSummary: summarizeQdnSyncPayload(importedPayload), + diff, + confirmLabel: "Load backup", + onConfirm: async () => { + if (disposed) return; + skipNextQdnPublishPromptRef.current = true; + setFileSystemPublic(imported.public); + setFileSystemPrivate(imported.private); + setFileSystemGroup(importedPayload.group); + setCurrentPath(["Root"]); + // Restore the private resource index from QDN backup too + if (importedPayload?.privateResourceIndex && myAddress?.address) { + await savePrivateResourceIndexEverywhere( + importedPayload.privateResourceIndex, + myAddress.address + ).catch((error) => { + console.error( + "Failed to restore private resource index from QDN backup:", + error + ); + }); + } + clearPublishNotice(); + dismissedPublishSnapshotRef.current = importedSnapshot; + setQdnSyncPrompt(null); + }, + onCancel: () => { + const localSnap = stableStringify( + normalizeQdnSyncPayloadForComparison(currentPayload) + ); + lastQdnSyncedSnapshotRef.current = localSnap; + dismissedPublishSnapshotRef.current = localSnap; + setQdnSyncPrompt(null); + }, + }); + } catch (error) {} + }; + + loadPublishedFileSystem(); + + return () => { + disposed = true; + }; + }, [ + myAddress?.name?.name, + activePublishName, + qdnFileSystemLoadReady, + fileSystemPublic, + fileSystemPrivate, + fileSystemGroup, + ]); + + useEffect(() => { + if (fileSystemPublic && fileSystemPrivate && myAddress?.address) { + const syncFilesystemState = async () => { + const privateResourceIndex = await getPersistedPrivateResourceIndex( + myAddress?.address, + [myAddress?.name?.name, activePublishName].filter(Boolean) + ); + + const payload = { + public: fileSystemPublic ?? [], + private: fileSystemPrivate ?? [], + group: fileSystemGroup || {}, + ...(privateResourceIndex ? { privateResourceIndex } : {}), + }; + + saveFileSystemQManagerEverywhere(payload, myAddress?.address).catch( + (error) => { + console.error( + "Failed to persist Q-Manager filesystem state:", + error + ); + } + ); + + if (skipNextQdnPublishPromptRef.current) { + skipNextQdnPublishPromptRef.current = false; + return; + } + + queueQdnPublishPrompt(payload); + }; + + syncFilesystemState(); + } + }, [ + fileSystemPublic, + fileSystemPrivate, + fileSystemGroup, + myAddress?.address, + myAddress?.name?.name, + activePublishName, + autoQdnFileSystemSync, + privateIndexRevision, + ]); const addDirectoryToCurrent = (directoryName) => { if (!directoryName || currentPath.length === 0) return false; - const updatedFileSystem = JSON.parse(JSON.stringify(fileSystem)); // Deep copy to avoid state mutation + const sourceFileSystem = fileSystemSnapshotRef.current || fileSystem || []; + const updatedFileSystem = JSON.parse(JSON.stringify(sourceFileSystem)); // Deep copy to avoid state mutation const targetFolder = currentPath[currentPath.length - 1]; // Current directory const parents = currentPath.slice(0, -1); // Parent directories @@ -279,21 +2923,21 @@ const [fileSystem, setFileSystem] = useMemo(() => { children: [], }); + fileSystemSnapshotRef.current = updatedFileSystem; setFileSystem(updatedFileSystem); // Update the state + queueQdnPublishPrompt( + buildQdnSyncPayload({ updatedTree: updatedFileSystem }) + ); return true; } return false; // Current directory not found }; - const addNodeByPath = ( - pathArray = currentPath, - newNode, - nodes = fileSystem - ) => { + const addNodeByPath = (pathArray = currentPath, newNode) => { if (pathArray.length === 0) return false; - - const updatedFileSystem = JSON.parse(JSON.stringify(nodes)); // Deep copy to avoid mutating state + const sourceFileSystem = fileSystemSnapshotRef.current || fileSystem || []; + const updatedFileSystem = JSON.parse(JSON.stringify(sourceFileSystem)); // Deep copy to avoid mutating state const target = pathArray[pathArray.length - 1]; // Last item is the target directory const parents = pathArray.slice(0, -1); // All but the last item are parent directories @@ -312,22 +2956,27 @@ const [fileSystem, setFileSystem] = useMemo(() => { const targetNode = currentNodes.find( (node) => node.name === target && node.type === "folder" ); - if (targetNode) { - targetNode.children = targetNode.children || []; + if (!targetNode) return false; // Target directory not found - // Ensure unique name for the new node based on type - const existingNames = targetNode.children - .filter((child) => child.type === newNode.type) // Only check for conflicts within the same type - .map((child) => child.name); + targetNode.children = targetNode.children || []; - newNode.name = ensureUniqueName(newNode.name, existingNames); + // Ensure unique name for the new node based on type + const existingNames = targetNode.children + .filter((child) => child.type === newNode.type) // Only check for conflicts within the same type + .map((child) => child.name); - targetNode.children.push(newNode); - setFileSystem(updatedFileSystem); // Update the state - return true; + const nextNode = { ...newNode }; + nextNode.name = ensureUniqueName(nextNode.name, existingNames); + if (nextNode.type === "file" && !nextNode.displayName) { + nextNode.displayName = nextNode.name; } - return false; // Target directory not found + targetNode.children.push(nextNode); + fileSystemSnapshotRef.current = updatedFileSystem; + setFileSystem(updatedFileSystem); + queueQdnPublishPrompt(buildQdnSyncPayload({ updatedTree: updatedFileSystem })); + + return true; }; const removeByNodePath = async ( @@ -365,7 +3014,10 @@ const [fileSystem, setFileSystem] = useMemo(() => { ); if (fileIndex !== -1) { targetFolderNode.children.splice(fileIndex, 1); // Remove the file from the children array - setFileSystem(updatedFileSystem); // Update the state + setFileSystem(updatedFileSystem); + queueQdnPublishPrompt( + buildQdnSyncPayload({ updatedTree: updatedFileSystem }) + ); return true; } @@ -391,11 +3043,230 @@ const [fileSystem, setFileSystem] = useMemo(() => { return newName; }; + const cloneInitialFileSystem = () => + JSON.parse(JSON.stringify(initialFileSystem)); + + const buildResourceKey = (file) => + `${file?.qortalName || ""}|${file?.service || ""}|${file?.identifier || ""}|${ + file?.group || 0 + }`; + + const collectResourceKeys = (nodes, collected = new Set()) => { + if (!Array.isArray(nodes)) return collected; + for (const node of nodes) { + if (!node) continue; + if (node.type === "file") { + collected.add(buildResourceKey(node)); + } + if (Array.isArray(node.children)) { + collectResourceKeys(node.children, collected); + } + } + return collected; + }; + + const mergeDiscoveredFilesIntoTree = (treeNodes, filesToAdd) => { + const nextTree = + Array.isArray(treeNodes) && treeNodes.length > 0 + ? JSON.parse(JSON.stringify(treeNodes)) + : cloneInitialFileSystem(); + + if (!nextTree[0]) { + nextTree[0] = { type: "folder", name: "Root", children: [] }; + } + nextTree[0].children = nextTree[0].children || []; + + let recoveredFolder = nextTree[0].children.find( + (child) => + child.type === "folder" && child.name === RECOVERED_IMPORTS_FOLDER + ); + + if (!recoveredFolder) { + recoveredFolder = { + type: "folder", + name: RECOVERED_IMPORTS_FOLDER, + children: [], + }; + nextTree[0].children.push(recoveredFolder); + } + + recoveredFolder.children = recoveredFolder.children || []; + + const existingResourceKeys = collectResourceKeys(nextTree); + const existingNames = recoveredFolder.children + .filter((child) => child.type === "file") + .map((child) => child.name); + + let addedCount = 0; + for (const file of filesToAdd) { + const key = buildResourceKey(file); + if (existingResourceKeys.has(key)) continue; + + const uniqueName = ensureUniqueName( + file?.name || file?.identifier || "Recovered file", + existingNames + ); + existingNames.push(uniqueName); + existingResourceKeys.add(key); + + recoveredFolder.children.push({ + ...file, + type: "file", + name: uniqueName, + displayName: file?.displayName || uniqueName, + }); + addedCount++; + } + + return { + nextTree, + addedCount, + }; + }; + + const discoverAndImportPublishedQManagerFiles = async () => { + const promise = (async () => { + const ownerName = await resolvePreferredName(currentName, myAddress?.address); + if (!ownerName) { + throw new Error("Could not determine your Qortal name"); + } + + const discovered = await discoverQManagerResourcesByName(ownerName); + if (!Array.isArray(discovered) || discovered.length === 0) { + throw new Error("No previously published Q-Manager files were found"); + } + + const groupedByTarget = discovered.reduce( + (acc, resource) => { + const identifier = resource?.identifier || ""; + const groupFromIdentifier = parseGroupQManagerIdentifier(identifier); + const inferredGroupId = + Number(groupFromIdentifier?.groupId) || + Number(resource?.groupId) || + 0; + const normalizedResource = { + type: "file", + name: resource?.name || resource?.identifier, + displayName: + resource?.displayName || + resource?.filename || + resource?.name || + resource?.identifier, + identifier, + service: resource?.service, + qortalName: resource?.qortalName || ownerName, + mimeType: resource?.mimeType, + sizeInBytes: resource?.sizeInBytes, + ...(groupFromIdentifier + ? groupFromIdentifier.isPrivateGroup + ? { encryptionType: "group" } + : {} + : resource?.encryptionType + ? { encryptionType: resource.encryptionType } + : {}), + ...(inferredGroupId > 0 + ? { + group: inferredGroupId, + groupName: + groups?.find( + (groupItem) => Number(groupItem.groupId) === Number(inferredGroupId) + )?.groupName || `Group ${inferredGroupId}`, + } + : {}), + }; + + if (inferredGroupId > 0) { + if (!acc.group[inferredGroupId]) { + acc.group[inferredGroupId] = []; + } + acc.group[inferredGroupId].push(normalizedResource); + return acc; + } + + const isPrivate = + normalizedResource?.service?.includes("_PRIVATE") || + normalizedResource?.identifier?.startsWith("p-"); + + if (isPrivate) { + acc.private.push(normalizedResource); + } else { + acc.public.push(normalizedResource); + } + return acc; + }, + { public: [], private: [], group: {} } + ); + + let totalAdded = 0; + + const publicMerge = mergeDiscoveredFilesIntoTree( + fileSystemPublic, + groupedByTarget.public + ); + if (publicMerge.addedCount > 0) { + setFileSystemPublic(publicMerge.nextTree); + totalAdded += publicMerge.addedCount; + } + + const privateMerge = mergeDiscoveredFilesIntoTree( + fileSystemPrivate, + groupedByTarget.private + ); + if (privateMerge.addedCount > 0) { + setFileSystemPrivate(privateMerge.nextTree); + totalAdded += privateMerge.addedCount; + } + + if (Object.keys(groupedByTarget.group).length > 0) { + const nextGroupState = + fileSystemGroup && !Array.isArray(fileSystemGroup) + ? { ...fileSystemGroup } + : {}; + + for (const [groupIdKey, files] of Object.entries( + groupedByTarget.group + )) { + const groupId = Number(groupIdKey); + const currentGroupTree = + nextGroupState[groupId] || cloneInitialFileSystem(); + const mergedGroup = mergeDiscoveredFilesIntoTree( + currentGroupTree, + files + ); + if (mergedGroup.addedCount > 0) { + nextGroupState[groupId] = mergedGroup.nextTree; + totalAdded += mergedGroup.addedCount; + } + } + + setFileSystemGroup(nextGroupState); + } + + if (totalAdded === 0) { + throw new Error( + "Previously published Q-Manager files were found, but they are already in your current structure" + ); + } + + setCurrentPath(["Root"]); + return { added: totalAdded }; + })(); + + openToast(promise, { + loading: "Finding and importing your published Q-Manager files...", + success: "Published Q-Manager files imported", + error: (err) => `Import failed: ${err?.error || err?.message || err}`, + }); + + return promise; + }; + const renameByPath = async (item) => { try { const pathArray = currentPath; // Get the current path const oldName = item.name; // Original name of the item - setNewName(oldName); + const oldDisplayName = getItemDisplayName(item); + setNewName(oldDisplayName); const newNameInput = await show("rename"); // Prompt user for the new name const type = item.type; // Type of the item (file or folder) @@ -430,7 +3301,7 @@ const [fileSystem, setFileSystem] = useMemo(() => { .map((child) => child.name); // Ensure a unique name if there is a conflict - if (existingNames.includes(newName)) { + if (type === "folder" && existingNames.includes(newName)) { let copyIndex = 1; const baseName = newName.replace(/(-copy\d*)?$/, ""); // Remove any existing "-copy" suffix while (existingNames.includes(newName)) { @@ -444,8 +3315,19 @@ const [fileSystem, setFileSystem] = useMemo(() => { (child) => child.name === oldName && child.type === type ); if (targetNode) { + if (type === "file") { + targetNode.displayName = newName; + setFileSystem(updatedFileSystem); + queueQdnPublishPrompt( + buildQdnSyncPayload({ updatedTree: updatedFileSystem }) + ); + return true; + } targetNode.name = newName; // Update the name setFileSystem(updatedFileSystem); // Update the state + queueQdnPublishPrompt( + buildQdnSyncPayload({ updatedTree: updatedFileSystem }) + ); return true; } @@ -462,13 +3344,13 @@ const [fileSystem, setFileSystem] = useMemo(() => { const pathArray = currentPath; // Get the current path const name = item.name; // Original name of the item const type = item.type; // Type of the item (file or folder) - + const updatedFileSystem = JSON.parse(JSON.stringify(fileSystem)); // Deep copy to avoid state mutation const targetFolder = pathArray[pathArray.length - 1]; // Current directory const parents = pathArray.slice(0, -1); // Parent directories - + let currentNodes = updatedFileSystem; - + // Traverse through parent directories for (const parent of parents) { const parentNode = currentNodes.find( @@ -480,7 +3362,7 @@ const [fileSystem, setFileSystem] = useMemo(() => { } currentNodes = parentNode.children; // Move deeper into the tree } - + // Find the target folder const currentFolderNode = currentNodes.find( (node) => node.name === targetFolder && node.type === "folder" @@ -489,20 +3371,26 @@ const [fileSystem, setFileSystem] = useMemo(() => { console.error("Current directory not found or empty"); return false; // Current directory not found or empty } - + // Find the target node by name and type const targetNodeIndex = currentFolderNode.children.findIndex( (child) => child.name === name && child.type === type ); - + if (targetNodeIndex !== -1) { // Update the node in the file system - currentFolderNode.children[targetNodeIndex] = { ...currentFolderNode.children[targetNodeIndex], ...item }; - + currentFolderNode.children[targetNodeIndex] = { + ...currentFolderNode.children[targetNodeIndex], + ...item, + }; + setFileSystem(updatedFileSystem); // Update the state + queueQdnPublishPrompt( + buildQdnSyncPayload({ updatedTree: updatedFileSystem }) + ); return true; } - + console.error("File or folder not found"); return false; // File or folder not found } catch (error) { @@ -510,38 +3398,106 @@ const [fileSystem, setFileSystem] = useMemo(() => { return false; } }; - const handleDragEnd = (event) => { const { active, over } = event; - if (!over) return; + if (!over) return; + + const activeKey = String(active?.id || ""); + const overKey = String(over?.id || ""); + if (!activeKey || activeKey === overKey) return; + + const activeItem = currentFolder?.children?.find( + (item) => getNodeSelectionKey(item) === activeKey + ); + if (!activeItem) return; + + const selectedKeys = new Set(selectedFileKeys); + const draggedItems = + activeItem?.type === "file" && + selectedKeys.has(activeKey) && + selectedVisibleFiles.length > 1 + ? currentFolder.children.filter( + (item) => + item?.type === "file" && selectedKeys.has(getNodeSelectionKey(item)) + ) + : [activeItem]; + + const nextTree = cloneFileSystemTree(fileSystem); + const sourcePathArray = currentPath; + + const breadcrumbTargetPath = parseBreadcrumbDropTarget(overKey); + if (breadcrumbTargetPath) { + if (breadcrumbTargetPath.join("/") === sourcePathArray.join("/")) { + return; + } + + let movedAny = false; + for (const node of draggedItems) { + movedAny = + moveNodeInTree( + nextTree, + node.name, + node.type, + sourcePathArray, + breadcrumbTargetPath + ) || movedAny; + } + + if (movedAny) { + setFileSystem(nextTree); + queueQdnPublishPrompt(buildQdnSyncPayload({ updatedTree: nextTree })); + clearSelection(); + } + return; + } - const activeIndex = currentFolder.children.findIndex( - (item) => item.name + item.type === active.id - ); - const overIndex = currentFolder.children.findIndex( - (item) => item.name + item.type === over.id + const overItem = currentFolder?.children?.find( + (item) => getNodeSelectionKey(item) === overKey ); + if (!overItem) return; - const updatedChildren = arrayMove( - currentFolder.children, - activeIndex, - overIndex - ); + if (overItem?.type === "folder") { + const targetPathArray = [...currentPath, overItem.name]; + let movedAny = false; + for (const node of draggedItems) { + movedAny = + moveNodeInTree( + nextTree, + node.name, + node.type, + sourcePathArray, + targetPathArray + ) || movedAny; + } - setFileSystem((prev) => { - const updateFolder = (folder) => { - if (folder.name === currentFolder.name) { - return { ...folder, children: updatedChildren }; - } - if (folder.children) { - return { ...folder, children: folder.children.map(updateFolder) }; - } - return folder; - }; - return prev.map(updateFolder); - }); + if (movedAny) { + setFileSystem(nextTree); + queueQdnPublishPrompt(buildQdnSyncPayload({ updatedTree: nextTree })); + clearSelection(); + } + return; + } + + if (activeItem?.type === "file" && overItem?.type === "file") { + const draggedNames = Array.from( + new Set([...draggedItems.map((node) => node.name), overItem.name]) + ); + const created = createFolderFromDroppedFilesInTree( + nextTree, + draggedNames, + sourcePathArray, + currentPath, + "New Folder" + ); + if (created) { + setFileSystem(nextTree); + queueQdnPublishPrompt(buildQdnSyncPayload({ updatedTree: nextTree })); + clearSelection(); + } + return; + } }; const deleteFolderInCurrent = async (folderName) => { @@ -576,6 +3532,9 @@ const [fileSystem, setFileSystem] = useMemo(() => { if (folderIndex !== -1) { currentFolderNode.children.splice(folderIndex, 1); // Remove the folder setFileSystem(updatedFileSystem); // Update the state + queueQdnPublishPrompt( + buildQdnSyncPayload({ updatedTree: updatedFileSystem }) + ); return true; } @@ -607,157 +3566,1094 @@ const [fileSystem, setFileSystem] = useMemo(() => { return currentNodes; // Returns the children array of the target folder }; - const moveNodeByPath = ( + const setFileSystemAtPath = (pathArray, updatedTree) => { + if (mode === "public") { + setFileSystemPublic(updatedTree); + } else if (mode === "private") { + setFileSystemPrivate(updatedTree); + } else { + setFileSystemGroup((prev) => ({ + ...(prev || {}), + [selectedGroup]: updatedTree, + })); + } + }; + + const buildQdnSyncPayload = ({ + updatedTree = null, + publicTree = fileSystemPublic, + privateTree = fileSystemPrivate, + groupTree = fileSystemGroup, + privateIndex = privateResourceIndex, + } = {}) => ({ + public: + mode === "public" && updatedTree ? updatedTree : publicTree ?? [], + private: + mode === "private" && updatedTree ? updatedTree : privateTree ?? [], + group: + mode === "group" && updatedTree + ? { + ...( + groupTree && !Array.isArray(groupTree) + ? groupTree + : initialGroupFileSystem + ), + ...(selectedGroup !== null && selectedGroup !== undefined + ? { [selectedGroup]: updatedTree } + : {}), + } + : groupTree && !Array.isArray(groupTree) + ? groupTree + : initialGroupFileSystem, + ...(privateIndex ? { privateResourceIndex: privateIndex } : {}), + }); + + const queueQdnPublishPrompt = (nextPayload) => { + if (!nextPayload) return; + const qdnOwnerName = activePublishName || myAddress?.name?.name; + if (!qdnOwnerName) return; + if (!fileSystemLoadedRef.current) return; + + const normalizedPayload = normalizeQdnSyncPayloadForComparison(nextPayload); + const currentSnapshot = stableStringify(normalizedPayload); + const baselineSnapshot = lastQdnSyncedSnapshotRef.current; + if ( + currentSnapshot === baselineSnapshot || + currentSnapshot === dismissedPublishSnapshotRef.current + ) { + setQdnBackupDirty(false); + qdnPublishPromptRef.current = null; + return; + } + const baselinePayload = (() => { + if (!baselineSnapshot) { + return { + public: [], + private: [], + group: {}, + privateResourceIndex: { entries: {} }, + }; + } + try { + return JSON.parse(baselineSnapshot); + } catch (error) { + return { + public: [], + private: [], + group: {}, + privateResourceIndex: { entries: {} }, + }; + } + })(); + const diff = diffQdnSyncPayload(baselinePayload, normalizedPayload); + if (!diff.hasChanges) { + setQdnBackupDirty(false); + qdnPublishPromptRef.current = null; + return; + } + + showPublishNotice({ + type: "publish", + title: "Publish Filesystem Backup Update?", + intro: + "Your local Q-Manager filesystem differs from the last QDN backup.", + fromLabel: "Last QDN backup", + toLabel: "Current local", + fromSummary: summarizeQdnSyncPayload(baselinePayload), + toSummary: summarizeQdnSyncPayload(normalizedPayload), + diff, + confirmLabel: "Publish update", + onConfirm: async () => { + try { + const publishPromise = publishFileSystemQManagerToQDN({ + fileSystemQManager: { + public: nextPayload.public, + private: nextPayload.private, + group: nextPayload.group, + }, + privateResourceIndex: nextPayload.privateResourceIndex, + activePublishName: qdnOwnerName, + }); + + openToast(publishPromise, { + loading: "Publishing filesystem structure to QDN...", + success: "Filesystem structure published to QDN", + error: (err) => + `Publish failed: ${err?.error || err?.message || err}`, + }); + await publishPromise; + lastQdnSyncedSnapshotRef.current = currentSnapshot; + dismissedPublishSnapshotRef.current = ""; + clearPublishNotice(); + } catch (error) { + console.error("Failed to publish filesystem backup update:", error); + } finally { + setQdnSyncPrompt(null); + } + }, + onCancel: () => { + dismissedPublishSnapshotRef.current = currentSnapshot; + setQdnSyncPrompt(null); + }, + }); + }; + + const cloneFileSystemTree = (tree) => JSON.parse(JSON.stringify(tree || [])); + + const getFolderNodeByPath = (tree, pathArray) => { + if ( + !Array.isArray(tree) || + !Array.isArray(pathArray) || + pathArray.length === 0 + ) { + return null; + } + + let currentNodes = tree; + let folderNode = null; + + for (const segment of pathArray) { + folderNode = currentNodes.find( + (node) => node?.type === "folder" && node?.name === segment + ); + if (!folderNode) { + return null; + } + currentNodes = Array.isArray(folderNode.children) ? folderNode.children : []; + } + + return folderNode; + }; + + const moveNodeInTree = ( + tree, nodeName, nodeType, sourcePathArray, targetPathArray ) => { + if ( + !Array.isArray(tree) || + !nodeName || + !nodeType || + !Array.isArray(sourcePathArray) || + !Array.isArray(targetPathArray) || + sourcePathArray.length === 0 || + targetPathArray.length === 0 + ) { + return false; + } + + if (nodeType === "folder") { + const sourceItemPathKey = [...sourcePathArray, nodeName].join("/"); + const targetFolderKey = targetPathArray.join("/"); + if ( + targetFolderKey === sourceItemPathKey || + targetFolderKey.startsWith(`${sourceItemPathKey}/`) + ) { + return false; + } + } + + const sourceFolderNode = getFolderNodeByPath(tree, sourcePathArray); + const targetFolderNode = getFolderNodeByPath(tree, targetPathArray); + if (!sourceFolderNode || !targetFolderNode) { + return false; + } + + sourceFolderNode.children = sourceFolderNode.children || []; + targetFolderNode.children = targetFolderNode.children || []; + + const sourceIndex = sourceFolderNode.children.findIndex( + (node) => node?.name === nodeName && node?.type === nodeType + ); + if (sourceIndex === -1) { + return false; + } + + const [nodeToMove] = sourceFolderNode.children.splice(sourceIndex, 1); + const existingNames = targetFolderNode.children + .filter((node) => node?.type === nodeType) + .map((node) => node.name); + const nextNode = { + ...nodeToMove, + name: ensureUniqueName(nodeToMove.name, existingNames), + }; + + if (nextNode.type === "file" && !nextNode.displayName) { + nextNode.displayName = nextNode.name; + } + if (nextNode.type === "folder") { + nextNode.children = Array.isArray(nextNode.children) + ? nextNode.children + : []; + } + + targetFolderNode.children.push(nextNode); + return true; + }; + + const createFolderFromDroppedFilesInTree = ( + tree, + nodeNames, + sourcePathArray, + targetPathArray, + folderName = "New Folder" + ) => { + if ( + !Array.isArray(tree) || + !Array.isArray(nodeNames) || + nodeNames.length === 0 || + !Array.isArray(sourcePathArray) || + !Array.isArray(targetPathArray) + ) { + return false; + } + + const sourceFolderNode = getFolderNodeByPath(tree, sourcePathArray); + const targetFolderNode = getFolderNodeByPath(tree, targetPathArray); + if (!sourceFolderNode || !targetFolderNode) { + return false; + } + + sourceFolderNode.children = sourceFolderNode.children || []; + targetFolderNode.children = targetFolderNode.children || []; + + const desiredNames = new Set(nodeNames); + const nodesToMove = []; + sourceFolderNode.children = sourceFolderNode.children.filter((node) => { + if (node?.type !== "file" || !desiredNames.has(node.name)) { + return true; + } + nodesToMove.push(node); + return false; + }); + + if (nodesToMove.length === 0) { + return false; + } + + const existingFolderNames = targetFolderNode.children + .filter((node) => node?.type === "folder") + .map((node) => node.name); + const nextFolderName = ensureUniqueName(folderName, existingFolderNames); + targetFolderNode.children.push({ + type: "folder", + name: nextFolderName, + children: nodesToMove.map((node) => ({ + ...node, + ...(node?.type === "file" && !node?.displayName + ? { displayName: node.name } + : {}), + })), + }); + + return true; + }; + + const parseBreadcrumbDropTarget = (dropId) => { + if (typeof dropId !== "string") return null; + if (!dropId.startsWith("breadcrumb|")) return null; + const encodedPath = dropId.slice("breadcrumb|".length); + const pathArray = encodedPath.split("/").filter(Boolean); + return pathArray.length > 0 ? pathArray : null; + }; - if (!nodeName || !nodeType || sourcePathArray.length === 0) { + const moveNodeByPath = ( + nodeName, + nodeType, + sourcePathArray, + targetPathArray + ) => { + if ( + !nodeName || + !nodeType || + !Array.isArray(sourcePathArray) || + sourcePathArray.length === 0 + ) { console.error("Invalid parameters"); return false; } + if (!Array.isArray(targetPathArray) || targetPathArray.length === 0) { + console.error("Invalid target path"); + return false; + } + if (!fileSystem || !Array.isArray(fileSystem)) { + console.error("Current file system is not available"); + return false; + } + + const sourceFolderKey = sourcePathArray.join("/"); + const targetFolderKey = targetPathArray.join("/"); + if (sourceFolderKey === targetFolderKey) { + console.error("Source and target folders are the same"); + return false; + } + + // Prevent moving a folder into itself or one of its descendants. + if (nodeType === "folder") { + const sourceItemPathKey = [...sourcePathArray, nodeName].join("/"); + if ( + targetFolderKey === sourceItemPathKey || + targetFolderKey.startsWith(`${sourceItemPathKey}/`) + ) { + console.error("Cannot move a folder into itself or one of its children"); + return false; + } + } + + const updatedTree = cloneFileSystemTree(fileSystem); + const moved = moveNodeInTree( + updatedTree, + nodeName, + nodeType, + sourcePathArray, + targetPathArray + ); + if (!moved) { + console.error("Node not found in source folder"); + return false; + } + + setFileSystemAtPath(targetPathArray, updatedTree); + queueQdnPublishPrompt(buildQdnSyncPayload({ updatedTree })); + return true; + }; + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 10, // Set a distance to avoid triggering drag on small movements + }, + }), + useSensor(TouchSensor, { + activationConstraint: { + distance: 10, // Also apply to touch + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const visibleItems = useMemo(() => { + const baseItems = currentFolder?.children || []; + if (mode !== "group") return baseItems; + return baseItems.filter( + (item) => + item.type === "folder" || + (item.type === "file" && + Number(item?.group) === Number(selectedGroup)) + ); + }, [currentFolder?.children, mode, selectedGroup]); + + const resolvedVisibleItems = useMemo( + () => + visibleItems.map((item) => + resolvePrivateResourceItem(item, privateResourceIndex) + ), + [visibleItems, privateResourceIndex] + ); + + const selectedVisibleFiles = useMemo(() => { + return resolvedVisibleItems.filter( + (item) => + item?.type === "file" && + selectedFileKeys.includes(getNodeSelectionKey(item)) + ); + }, [resolvedVisibleItems, selectedFileKeys]); + + const visibleFileKeys = useMemo( + () => + resolvedVisibleItems + .filter((item) => item?.type === "file") + .map((item) => getNodeSelectionKey(item)), + [resolvedVisibleItems] + ); + + const selectedSizeSummary = useMemo(() => { + let totalBytes = 0; + let knownCount = 0; + for (const item of selectedVisibleFiles) { + const size = getItemSizeBytes(item); + if (size === null) continue; + totalBytes += size; + knownCount++; + } + return { + totalBytes, + knownCount, + unknownCount: selectedVisibleFiles.length - knownCount, + }; + }, [selectedVisibleFiles]); + + const resolvedSelectedFile = useMemo( + () => + selectedFile + ? resolvePrivateResourceItem(selectedFile, privateResourceIndex) + : null, + [selectedFile, privateResourceIndex] + ); + + const resolvedPreviewFile = useMemo( + () => + previewFile + ? resolvePrivateResourceItem(previewFile, privateResourceIndex) + : null, + [previewFile, privateResourceIndex] + ); + + const setSelectionAnchor = (key) => { + selectionAnchorKeyRef.current = key || ""; + }; + + const selectFileRange = (item) => { + if (item?.type !== "file") return; + const key = getNodeSelectionKey(item); + const anchorKey = selectionAnchorKeyRef.current; + const anchorIndex = visibleFileKeys.indexOf(anchorKey); + const targetIndex = visibleFileKeys.indexOf(key); + + if (anchorIndex === -1 || targetIndex === -1) { + setSelectionAnchor(key); + setSelectedFileKeys([key]); + return; + } + + const startIndex = Math.min(anchorIndex, targetIndex); + const endIndex = Math.max(anchorIndex, targetIndex); + const rangeKeys = visibleFileKeys.slice(startIndex, endIndex + 1); + setSelectionAnchor(key); + setSelectedFileKeys(rangeKeys); + }; + + const toggleSelectFile = (item, event = null) => { + if (item?.type !== "file") return; + const key = getNodeSelectionKey(item); + + if (event?.shiftKey) { + event.preventDefault?.(); + event.stopPropagation?.(); + selectFileRange(item); + return; + } + if (event?.metaKey || event?.ctrlKey) { + event.preventDefault?.(); + event.stopPropagation?.(); + setSelectionAnchor(key); + setSelectedFileKeys((prev) => + prev.includes(key) + ? prev.filter((entry) => entry !== key) + : [...prev, key] + ); + return; + } + + setSelectionAnchor(key); + setSelectedFileKeys((prev) => + prev.includes(key) + ? prev.filter((entry) => entry !== key) + : [...prev, key] + ); + }; + + const togglePinByPath = (item) => { + if (!item || item?.type !== "file") return; + updateByPath({ + ...item, + pinned: !item?.pinned, + }); + }; + + const clearSelection = () => { + setSelectionAnchor(""); + setSelectedFileKeys([]); + }; + + const removeSelectedFromManager = () => { + if (selectedVisibleFiles.length === 0) return; + const selectedKeys = new Set( + selectedVisibleFiles.map((item) => getNodeSelectionKey(item)) + ); const updatedFileSystem = JSON.parse(JSON.stringify(fileSystem)); + let currentNodes = updatedFileSystem; - // Updated traverseToFolder function (as above) - const traverseToFolder = (pathArray, nodes) => { - let currentNodes = nodes; - let folder = null; + for (const parent of currentPath.slice(0, -1)) { + const parentNode = currentNodes.find( + (node) => node.name === parent && node.type === "folder" + ); + if (!parentNode) return; + currentNodes = parentNode.children; + } + + const targetFolder = currentNodes.find( + (node) => + node.name === currentPath[currentPath.length - 1] && + node.type === "folder" + ); + if (!targetFolder || !Array.isArray(targetFolder.children)) return; + + targetFolder.children = targetFolder.children.filter((child) => { + if (child?.type !== "file") return true; + return !selectedKeys.has(getNodeSelectionKey(child)); + }); + + setFileSystem(updatedFileSystem); + queueQdnPublishPrompt(buildQdnSyncPayload({ updatedTree: updatedFileSystem })); + clearSelection(); + }; + + const getBulkMoveRootDirectories = () => { + if (Array.isArray(fileSystem)) { + return fileSystem; + } + + if (fileSystem && typeof fileSystem === "object") { + if (selectedGroup && Array.isArray(fileSystem[selectedGroup])) { + return fileSystem[selectedGroup]; + } + + const firstTree = Object.values(fileSystem).find((tree) => + Array.isArray(tree) + ); + if (firstTree) { + return firstTree; + } + } + + return []; + }; + + const renderFolderTreeForBulkMove = (directories, path = []) => { + const directoryNodes = Array.isArray(directories) ? directories : []; + return directoryNodes + .filter((node) => node?.type === "folder") + .map((dir) => { + const fullPath = [...path, dir.name]; + const isSelectedTarget = + fullPath.join("/") === bulkMoveTargetPath.join("/"); + const isCurrentPath = fullPath.join("/") === currentPath.join("/"); + + return ( + + { + if (isCurrentPath) return; + setBulkMoveTargetPath(fullPath); + }} + sx={{ + width: "100%", + justifyContent: "flex-start", + px: "8px", + py: "6px", + borderRadius: "8px", + opacity: isCurrentPath ? 0.6 : 1, + backgroundColor: isSelectedTarget + ? "rgba(89,178,255,0.2)" + : "transparent", + }} + > + + {dir.name} + + {Array.isArray(dir.children) && dir.children.length > 0 && ( + + {renderFolderTreeForBulkMove(dir.children, fullPath)} + + )} + + ); + }); + }; + + const moveSelectedToPath = () => { + if (selectedVisibleFiles.length === 0 || bulkMoveTargetPath.length === 0) { + return; + } + + const selectedKeys = new Set( + selectedVisibleFiles.map((item) => getNodeSelectionKey(item)) + ); + + const updatedFileSystem = JSON.parse(JSON.stringify(fileSystem)); - for (const dir of pathArray) { - folder = currentNodes.find( - (node) => node.name === dir && node.type === "folder" + const traverseFolder = (pathArray) => { + let nodes = updatedFileSystem; + let folder = null; + for (const part of pathArray) { + folder = nodes.find( + (node) => node.name === part && node.type === "folder" ); - if (!folder) { - console.error(`Folder not found: ${dir}`); - return null; + if (!folder) return null; + nodes = folder.children; + } + return folder; + }; + + const sourceFolder = traverseFolder(currentPath); + const targetFolder = traverseFolder(bulkMoveTargetPath); + if (!sourceFolder || !targetFolder) return; + + sourceFolder.children = sourceFolder.children || []; + targetFolder.children = targetFolder.children || []; + + const movedNodes = []; + sourceFolder.children = sourceFolder.children.filter((node) => { + if (node?.type !== "file") return true; + if (!selectedKeys.has(getNodeSelectionKey(node))) return true; + movedNodes.push(node); + return false; + }); + + for (const node of movedNodes) { + const existingNames = targetFolder.children + .filter((child) => child.type === node.type) + .map((child) => child.name); + node.name = ensureUniqueName(node.name, existingNames); + targetFolder.children.push(node); + } + + setFileSystem(updatedFileSystem); + queueQdnPublishPrompt(buildQdnSyncPayload({ updatedTree: updatedFileSystem })); + + setShowBulkMoveModal(false); + setBulkMoveTargetPath([]); + clearSelection(); + }; + + const isPrivateService = (service) => { + return ( + typeof service === "string" && service.toUpperCase().includes("_PRIVATE") + ); + }; + + const isPrivateFile = (file) => { + // Check encryptionType field (e.g., "private" or "group") + const encryptionType = + typeof file?.encryptionType === "string" + ? file.encryptionType.toLowerCase() + : ""; + if (encryptionType === "private") { + return true; + } + // Also check if service name indicates private + const service = getServiceName(file); + if ( + typeof service === "string" && + service.toUpperCase().includes("_PRIVATE") + ) { + return true; + } + // Check identifier prefix + const identifier = file?.identifier || ""; + if (typeof identifier === "string") { + const idLower = identifier.toLowerCase(); + if ( + idLower.startsWith("p-") || + idLower.startsWith("pvt-") + ) { + return true; + } + } + return false; + }; + + const isGroupEncryptedFile = (file) => { + const encryptionType = + typeof file?.encryptionType === "string" + ? file.encryptionType.toLowerCase() + : ""; + if (encryptionType === "group" && isPrivateGroupQManagerIdentifier(file?.identifier)) { + return true; + } + return isPrivateGroupQManagerIdentifier(file?.identifier); + }; + + const getPrivateServiceName = (file) => { + const baseService = file.service?.toUpperCase() || ""; + const isAlreadyPrivate = baseService.includes("_PRIVATE"); + return isAlreadyPrivate ? baseService : `${baseService}_PRIVATE`; + }; + + const deleteSelectedFromQDN = async () => { + if (selectedVisibleFiles.length === 0) return; + const filesToDelete = [...selectedVisibleFiles]; + + const accountPublicKey = myAddress?.publicKey || ""; + const myName = activePublishName || myAddress?.name?.name || ""; + const tombstonePayload = btoa("d"); + + const buildDeletePublishResource = async (file) => { + if (isGroupEncryptedFile(file)) { + const groupId = normalizeGroupId(file?.groupId || file?.group); + if (!groupId) { + throw new Error("missing group id"); + } + + const encryptedResponse = await requestQortal({ + action: "ENCRYPT_QORTAL_GROUP_DATA", + data64: tombstonePayload, + groupId, + }); + const encryptedData = + typeof encryptedResponse === "string" + ? encryptedResponse + : encryptedResponse?.data64 || encryptedResponse?.encryptedData; + if (!encryptedData) { + throw new Error("group encryption failed"); + } + + return { + name: myName, + service: file.service, + identifier: file.identifier, + data64: encryptedData, + externalEncrypt: true, + }; + } + + if (isPrivateFile(file) || isPrivateService(file.service)) { + const targetService = getPrivateServiceName(file) || file.service; + + try { + const encryptedResponse = await requestQortal({ + action: "ENCRYPT_DATA_WITH_SHARING_KEY", + base64: tombstonePayload, + }); + const encryptedData = + typeof encryptedResponse === "string" + ? encryptedResponse + : encryptedResponse?.data64 || encryptedResponse?.encryptedData; + if (encryptedData) { + return { + name: myName, + service: targetService, + identifier: file.identifier, + data64: encryptedData, + externalEncrypt: true, + }; + } + } catch (error) { + console.error( + "[DELETE] ENCRYPT_DATA_WITH_SHARING_KEY failed for", + file.service, + file.identifier, + error + ); + } + + try { + const encryptParams = { + action: "ENCRYPT_DATA", + data64: tombstonePayload, + }; + if (accountPublicKey) { + encryptParams.publicKey = accountPublicKey; + } + const encryptedResponse = await requestQortal(encryptParams); + const encryptedData = + typeof encryptedResponse === "string" + ? encryptedResponse + : encryptedResponse?.data64 || encryptedResponse?.encryptedData; + if (encryptedData) { + return { + name: myName, + service: targetService, + identifier: file.identifier, + data64: encryptedData, + externalEncrypt: true, + }; + } + } catch (error) { + console.error( + "[DELETE] ENCRYPT_DATA failed for", + file.service, + file.identifier, + error + ); } - currentNodes = folder.children; + + throw new Error("encryption failed"); } - return folder; + return { + name: myName, + service: file.service, + identifier: file.identifier, + data64: tombstonePayload, + }; }; - // Locate the source folder (where the node currently resides) - const sourceFolder = traverseToFolder(sourcePathArray, updatedFileSystem); - if (!sourceFolder || !sourceFolder.children) { - console.error("Source folder not found"); - return false; - } - + const promise = (async () => { + skipNextQdnPublishPromptRef.current = true; + removeSelectedFromManager(); - // Locate the target folder - const targetFolder = - targetPathArray.length > 0 - ? traverseToFolder(targetPathArray, updatedFileSystem) - : { children: updatedFileSystem }; - - if (!targetFolder || !targetFolder.children) { - console.error("Target folder not found"); - return false; - } + const failures = []; + const deletionResources = []; + for (const file of filesToDelete) { + if (!file?.identifier || !file?.service) continue; - // Find and remove the node from the source folder - const nodeIndex = sourceFolder.children.findIndex( - (node) => node.name === nodeName && node.type === nodeType - ); + try { + const publishResource = await buildDeletePublishResource(file); + deletionResources.push(publishResource); + } catch (error) { + console.error("[DELETE] Failed to build tombstone resource:", error); + failures.push( + `${file.service}/${file.identifier} (${ + error?.message || "encryption failed" + })` + ); + } + } + if (deletionResources.length === 0) { + throw new Error( + failures.length > 0 + ? `Failed to prepare tombstones for: ${failures.join(", ")}` + : "No deletable files were selected" + ); + } - if (nodeIndex === -1) { - console.error("Node not found in source folder"); - return false; - } + if (deletionResources.length === 1) { + const publishResult = await requestQortal({ + action: "PUBLISH_QDN_RESOURCE", + ...deletionResources[0], + }); + if (!publishResult?.identifier) { + throw new Error("Failed to publish tombstone"); + } + } else { + const publishResult = await requestQortal({ + action: "PUBLISH_MULTIPLE_QDN_RESOURCES", + name: myName, + resources: deletionResources, + }); + if (!publishResult || publishResult?.error) { + throw new Error( + publishResult?.error || "Failed to publish tombstones" + ); + } + } - const [nodeToMove] = sourceFolder.children.splice(nodeIndex, 1); + // Refresh the QDN snapshot by fetching directly from the QDN resource endpoint + // so the diff sees the actual published state after the tombstone publish. + let currentQdnState = null; + try { + const qdnOwnerName = + (await resolvePreferredName(myName, myAddress?.address)) || myName; + const response = await fetch( + `/arbitrary/DOCUMENT_PRIVATE/${qdnOwnerName}/${QDN_STRUCTURE_IDENTIFIER}?encoding=base64` + ); + if (response.ok) { + const encryptedData = await response.text(); + const decryptedData = await requestQortal({ + action: "DECRYPT_DATA", + encryptedData, + }); + const decryptedBytes = base64ToUint8Array(decryptedData); + currentQdnState = uint8ArrayToObject(decryptedBytes); + } + } catch (e) { + // If we can't fetch QDN state, fall back to local state + currentQdnState = { + public: fileSystemPublic, + private: fileSystemPrivate, + group: fileSystemGroup, + }; + } + // Build synced snapshot using the QDN state we just fetched. + // The QDN backup includes both filesystem and privateResourceIndex. + // The diff compares each independently so filesystem deletions and + // private index additions/removals are all shown accurately. + // + // After a file delete, the local private index still has the file's entries + // (they're published separately to QDN). So we merge the local private index + // into the snapshot so the diff sees private index entries as "unchanged" + // on both sides. This is correct because the private index is managed locally + // and the QDN backup only stores the filesystem structure. + const localPrivateIndex = await getPersistedPrivateResourceIndex( + myAddress?.address, + [myAddress?.name?.name, activePublishName].filter(Boolean) + ).catch(() => null); - // Check for naming conflicts in the target folder - const existingNames = targetFolder.children - .filter((node) => node.type === nodeType) - .map((node) => node.name); + const syncedSnapshot = stableStringify( + normalizeQdnSyncPayloadForComparison({ + public: currentQdnState?.public || fileSystemPublic, + private: currentQdnState?.private || fileSystemPrivate, + group: currentQdnState?.group || fileSystemGroup, + ...(localPrivateIndex ? { privateResourceIndex: localPrivateIndex } : {}), + }) + ); - nodeToMove.name = ensureUniqueName(nodeToMove.name, existingNames); + if (failures.length > 0) { + lastQdnSyncedSnapshotRef.current = syncedSnapshot; + dismissedPublishSnapshotRef.current = syncedSnapshot; + throw new Error( + `Failed to publish tombstone for: ${failures.join(", ")}. ` + + "The file was removed locally but the QDN publish may have failed. " + + "Please try again." + ); + } - // Add the node to the target folder - targetFolder.children.push(nodeToMove); + lastQdnSyncedSnapshotRef.current = syncedSnapshot; + dismissedPublishSnapshotRef.current = syncedSnapshot; + })(); + openToast(promise, { + loading: "Deleting selected files from QDN...", + success: "Selected files deleted from QDN", + error: (err) => `Delete failed: ${err?.error || err?.message || err}`, + }); - // Update the state - setFileSystem(updatedFileSystem); - return true; + return promise; }; - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 10, // Set a distance to avoid triggering drag on small movements - }, - }), - useSensor(TouchSensor, { - activationConstraint: { - distance: 10, // Also apply to touch - }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }) - ); - if (!fileSystem) return null; - - return ( - - { -setCurrentPath(['Root']) -setMode(newValue) - }} centered> - - - - - - - - Q-Manager - - + + { + clearSelection(); + setCurrentPath(["Root"]); + setMode(newValue); + }} + centered + > + + + + + + + + + Q-Manager + + + + + + - {mode === 'group' && ( - - + + {mode === "group" && ( + + - - )} - {mode === 'group' && !selectedGroup ? ( - <> - - ) : ( - <> - - {/* Add Folder Button */} - { - try { - await show("export-data"); - } catch (error) {} - }} - sx={{ - gap: '5px', - background: '#4444', - padding: '5px', - borderRadius: '5px', - }} - > - - - Save data - - - { - try { - const dirname = await show("new-directory"); - addDirectoryToCurrent(dirname); - } catch (error) { - } finally { - setNewDirName(""); - } - }} - sx={{ - gap: '5px', - background: '#4444', - padding: '5px', - borderRadius: '5px', - }} - > - - - +Folder - - - - {/* Add File Button */} - { - setIsOpenPublish(true); - }} - sx={{ - gap: '5px', - background: '#4444', - padding: '5px', - borderRadius: '5px', - }} - > - - - +File - - - + + )} - {/* */} - - - - - - item.type === 'folder' || (item.type === 'file' && item?.group === selectedGroup )) : currentFolder?.children)?.map((item) => item.name + item.type)} + + + + - {(mode === 'group' ? currentFolder?.children?.filter((item)=> item.type === 'folder' || (item.type === 'file' && item?.group === selectedGroup) ) : currentFolder?.children)?.map((item) => ( - { - renameByPath(item); - }} - removeFile={() => { - removeByNodePath(undefined, item.name, undefined); - }} - removeDirectory={() => { - deleteFolderInCurrent(item.name); - }} - onClick={() => { - if (item.type === "folder") { - handleNavigate(item.name); - } else if (item.type === "file") { - setSelectedFile(item); - } - }} - /> - ))} - - - - - )} - - + + + getNodeSelectionKey(item) + )} + > + {resolvedVisibleItems?.map((item) => ( + toggleSelectFile(item, event)} + moveNode={moveNodeByPath} + onPreview={() => { + setPreviewFile(item); + }} + onHydrateMetadata={(metadata) => { + if (!metadata || Object.keys(metadata).length === 0) + return; + updateByPath({ + ...item, + ...metadata, + }); + setSelectedFile((prev) => { + if (!prev) return prev; + if ( + getNodeSelectionKey(prev) !== + getNodeSelectionKey(item) + ) { + return prev; + } + return { + ...prev, + ...metadata, + }; + }); + }} + onTogglePin={() => { + togglePinByPath(item); + }} + rename={() => { + renameByPath(item); + }} + removeFile={() => { + removeByNodePath(undefined, item.name, undefined); + }} + removeDirectory={() => { + deleteFolderInCurrent(item.name); + }} + onClick={() => { + if (item.type === "folder") handleNavigate(item.name); + else setSelectedFile(item); + }} + /> + ))} + + + + + )} + {isOpenPublish && ( setIsOpenPublish(false)} selectedAction={{ - action: "PUBLISH_QDN_RESOURCE", + action: "PUBLISH_MULTIPLE_QDN_RESOURCES", + files: [], }} mode={mode} groups={groups} @@ -1006,13 +5130,22 @@ setMode(newValue) setNewDirName(e.target.value)} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + const trimmedName = newDirName.trim(); + if (!trimmedName) return; + onOk(trimmedName); + }} + autoFocus /> @@ -1023,7 +5156,6 @@ setMode(newValue) disabled={!newDirName} variant="contained" onClick={() => onOk(newDirName)} - autoFocus > Save @@ -1036,9 +5168,9 @@ setMode(newValue) - + + + + - + + }} + > + Import filesystem structure from QDN + + @@ -1134,15 +5393,211 @@ setMode(newValue) )} )} + { + setShowBulkMoveModal(false); + setBulkMoveTargetPath([]); + }} + > + + + Move {selectedVisibleFiles.length} selected file + {selectedVisibleFiles.length === 1 ? "" : "s"} + + + Choose target folder + + {renderFolderTreeForBulkMove(getBulkMoveRootDirectories())} + + + + + + + qdnSyncPrompt?.onCancel?.()} + maxWidth="sm" + fullWidth + PaperProps={{ + style: { + backgroundColor: "rgb(39, 40, 44)", + color: "#ffffff", + }, + }} + > + {qdnSyncPrompt?.title} + + + {qdnSyncPrompt?.intro} + + + {[ + [qdnSyncPrompt?.fromLabel, qdnSyncPrompt?.fromSummary], + [qdnSyncPrompt?.toLabel, qdnSyncPrompt?.toSummary], + ].map(([label, summary], index) => ( + + + {label} + + + Filesystem files: {summary?.fileSystem?.files ?? 0} + + + Filesystem groups: {summary?.fileSystem?.groups ?? 0} + + + Filesystem size: {summary?.fileSystem?.sizeLabel || "Unknown"} + + + Private index entries: {summary?.privateIndex?.entries ?? 0} + + + Private index size:{" "} + {summary?.privateIndex?.sizeLabel || "Unknown"} + + + ))} + + + + Detected changes + + + Filesystem + + + Added:{" "} + {formatChangeList(qdnSyncPrompt?.diff?.fileSystem?.added || [])} + + + Removed:{" "} + {formatChangeList(qdnSyncPrompt?.diff?.fileSystem?.removed || [])} + + + Changed:{" "} + {formatChangeList(qdnSyncPrompt?.diff?.fileSystem?.changed || [])} + + + Private index + + + Added:{" "} + {formatChangeList(qdnSyncPrompt?.diff?.privateIndex?.added || [])} + + + Removed:{" "} + {formatChangeList( + qdnSyncPrompt?.diff?.privateIndex?.removed || [] + )} + + + Changed:{" "} + {formatChangeList( + qdnSyncPrompt?.diff?.privateIndex?.changed || [] + )} + + + + + + + + - {selectedFile && ( + {resolvedSelectedFile && ( + )} + {resolvedPreviewFile && ( + { + if (!metadata || Object.keys(metadata).length === 0) return; + updateByPath({ + ...resolvedPreviewFile, + ...metadata, + }); + setPreviewFile((prev) => (prev ? { ...prev, ...metadata } : prev)); + setSelectedFile((prev) => { + if (!prev) return prev; + if ( + getNodeSelectionKey(prev) !== getNodeSelectionKey(previewFile) + ) { + return prev; + } + return { ...prev, ...metadata }; + }); + }} + onClose={() => setPreviewFile(null)} /> )} @@ -1157,5 +5612,5 @@ export const AppsContainer = styled(Box)(({ theme }) => ({ flexWrap: "wrap", alignItems: "flex-start", alignSelf: "center", - paddingBottom: '50px' + paddingBottom: "50px", })); diff --git a/src/ShowAction.jsx b/src/ShowAction.jsx index 485a4d1..cb8a443 100644 --- a/src/ShowAction.jsx +++ b/src/ShowAction.jsx @@ -14,14 +14,15 @@ import { PUBLISH_QDN_RESOURCE } from "./actions/PUBLISH_QDN_RESOURCE"; import { PUBLISH_MULTIPLE_QDN_RESOURCES } from "./actions/PUBLISH_MULTIPLE_QDN_RESOURCES"; import { OPEN_NEW_TAB } from "./actions/OPEN_NEW_TAB"; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +/** @type {import('react').ForwardRefExoticComponent }>} */ export const Transition = React.forwardRef(function Transition(props, ref) { - return ; + return {props.children}; }); -export const ShowAction = ({ selectedAction, handleClose, myName, addNodeByPath, mode , groups, selectedGroup}) => { +export const ShowAction = ({ selectedAction, handleClose, myName, accountAddress, accountPublicKey, addNodeByPath, mode , groups, selectedGroup, }) => { const ActionComponent = useMemo(() => { switch (selectedAction?.action) { - + case "PUBLISH_QDN_RESOURCE": return PUBLISH_QDN_RESOURCE; case "PUBLISH_MULTIPLE_QDN_RESOURCES": @@ -70,7 +71,7 @@ export const ShowAction = ({ selectedAction, handleClose, myName, addNodeByPath, overflowY: "auto", }} > - + {/* ` @@ -74,7 +75,7 @@ await qortalRequest({ const executeQortalRequest = async () => { try { setIsLoading(true) - let account = await qortalRequest({ + let account = await requestQortal({ action: "CREATE_POLL", pollName: requestData?.pollName, pollDescription: requestData?.pollDescription, diff --git a/src/actions/OPEN_NEW_TAB.jsx b/src/actions/OPEN_NEW_TAB.jsx index 1e6716c..41aa5c2 100644 --- a/src/actions/OPEN_NEW_TAB.jsx +++ b/src/actions/OPEN_NEW_TAB.jsx @@ -5,6 +5,7 @@ import { DisplayCodeResponse } from "../components/DisplayCodeResponse"; import beautify from "js-beautify"; import Button from "../components/Button"; +import { requestQortal } from "../qapp/request"; export const Label = styled("label")( ({ theme }) => ` @@ -55,11 +56,11 @@ await qortalRequest({ const executeQortalRequest = async () => { try { setIsLoading(true) - // let account = await qortalRequest({ + // let account = await requestQortal({ // action: "OPEN_NEW_TAB", // qortalLink: requestData?.qortalLink, // }); - let account = await qortalRequest({ + let account = await requestQortal({ action: "CREATE_AND_COPY_EMBED_LINK", name: 'SHOULD MINTING REQUIRE A NAME?', type: 'POLL', diff --git a/src/actions/PUBLISH_MULTIPLE_QDN_RESOURCES.jsx b/src/actions/PUBLISH_MULTIPLE_QDN_RESOURCES.jsx index 748bc29..da58296 100644 --- a/src/actions/PUBLISH_MULTIPLE_QDN_RESOURCES.jsx +++ b/src/actions/PUBLISH_MULTIPLE_QDN_RESOURCES.jsx @@ -1,211 +1,451 @@ import React, { useState } from "react"; -import { Box, ButtonBase, CircularProgress, MenuItem, Select, styled } from "@mui/material"; -import { DisplayCode } from "../components/DisplayCode"; -import { DisplayCodeResponse } from "../components/DisplayCodeResponse"; - -import beautify from "js-beautify"; +import { + Box, + ButtonBase, + CircularProgress, + MenuItem, + Select, + Typography, + styled, +} from "@mui/material"; +import ShortUniqueId from "short-unique-id"; +import { + createImageThumbnailData64, + buildGroupQManagerIdentifier, + fileToBase64, + getGroupById, + normalizeGroupId, +} from "../utils"; +import { openToast } from "../components/openToast"; import Button from "../components/Button"; -import { useDropzone } from "react-dropzone"; -import { services } from "../constants"; - -export const Label = styled("label")( - ({ theme }) => ` - font-family: 'IBM Plex Sans', sans-serif; - font-size: 14px; - display: block; - margin-bottom: 4px; - font-weight: 400; - ` -); - -export const formatResponse = (code) => { - return beautify.js(code, { - indent_size: 2, // Number of spaces for indentation - space_in_empty_paren: true, // Add spaces inside parentheses - }); +import { privateServices, services } from "../constants"; +import { useDropzone } from "react-dropzone"; +import { requestQortal } from "../qapp/request"; +import { resolvePreferredName } from "../utils"; +import { upsertPrivateResourceIndexEntry } from "../storage"; + +const uid = new ShortUniqueId({ length: 10 }); + +const normalizeEncryptedSharingKeyResponse = (response) => { + if (response === null || response === undefined) { + return { + data64: "", + sharingKey: "", + publicKey: "", + }; + } + + if (typeof response === "string") { + const trimmed = response.trim(); + if (!trimmed) { + return { + data64: "", + sharingKey: "", + publicKey: "", + }; + } + + try { + const parsed = JSON.parse(trimmed); + if (parsed && typeof parsed === "object") { + return { + data64: + parsed?.data64 || + parsed?.data || + parsed?.encryptedData || + parsed?.payload || + "", + sharingKey: parsed?.key || parsed?.sharingKey || "", + publicKey: parsed?.publicKey || "", + raw: parsed, + }; + } + } catch (error) {} + + return { + data64: trimmed, + sharingKey: "", + publicKey: "", + raw: response, + }; + } + + if (typeof response === "object") { + return { + data64: + response?.data64 || + response?.data || + response?.encryptedData || + response?.payload || + "", + sharingKey: response?.key || response?.sharingKey || "", + publicKey: response?.publicKey || "", + raw: response, + }; + } + + return { + data64: String(response), + sharingKey: "", + publicKey: "", + raw: response, + }; }; -export const PUBLISH_MULTIPLE_QDN_RESOURCES = () => { + +export const Label = styled("label")` + font-family: 'IBM Plex Sans', sans-serif; + font-size: 14px; + display: block; + margin-bottom: 4px; + font-weight: 400; +`; + +export const PUBLISH_MULTIPLE_QDN_RESOURCES = ({ + files: initialFiles = [], + addNodeByPath, + myName, + accountAddress, + accountPublicKey, + mode, + groups, + selectedGroup, +}) => { + const [files, setFiles] = useState(initialFiles); const [requestData, setRequestData] = useState({ - service: "DOCUMENT", - identifier: "test-identifier", + service: mode === "private" ? "DOCUMENT_PRIVATE" : "DOCUMENT", }); + const [isLoading, setIsLoading] = useState(false); + const [response, setResponse] = useState(""); + const ownerName = typeof myName === "string" ? myName : ""; + const isImageFile = (candidate) => + typeof candidate?.type === "string" && + candidate.type.toLowerCase().startsWith("image/"); const { getRootProps, getInputProps } = useDropzone({ - maxFiles: 1, - onDrop: async (acceptedFiles) => { - const fileSelected = acceptedFiles[0]; - if (fileSelected) { - setFile(fileSelected); - } + multiple: true, + onDrop: (acceptedFiles) => { + // append new files to state + setFiles((prev) => [...prev, ...acceptedFiles]); }, }); - const [isLoading, setIsLoading] = useState(false); - const [file, setFile] = useState(null); - const [responseData, setResponseData] = useState( - formatResponse(`{ - "type": "PUBLISH_MULTIPLE_QDN_RESOURCES", - "timestamp": 1697286687406, - "reference": "3jU9WpEPAvu9iL3cMfVd2AUmn9AijJRzkGCxVtXfpuUFZubM8AFDcbk5XA9m5AhPfsbMDFkSDzPJnkjeLA5GA59E", - "fee": "0.01000000", - "signature": "3QJ1EUvX3rskVNaP3RWvJwb9DsGgHPvneWqBWS62PCcuCj5N4Ei9Tr4nFj4nQeMqMU2qNkVD3Sb59e7iUWkawH3s", - "txGroupId": 0, - "approvalStatus": "NOT_REQUIRED", - "creatorAddress": "Qhxphh7g5iNtxAyLLpPMZzp4X85yf2tVam", - "voterPublicKey": "C5spuNU1BAHZDEkxF3wnrAPRDuNrVceaDJ6tDKitenko", - "pollName": "A test poll 3", - "optionIndex": 1 - }`) - ); - const codePollName = ` -await qortalRequest({ - action: "PUBLISH_MULTIPLE_QDN_RESOURCES", - service: "${requestData?.service}", - identifier: "${requestData?.identifier}", // optional - data64: ${requestData?.data64 ? `"${requestData?.data64}"` : "empty"}, // base64 string. Remove this param if you are putting in a FILE object - file: ${file ? 'FILE OBJECT' : "empty"} // File Object. Remove this param if you are putting in a base64 string. -}); -`.trim(); - - const executeQortalRequest = async () => { - try { + // Utility: derive filename parts & identifier + const makeMeta = (file, isPublicGroup = false, groupId = selectedGroup) => { + const ext = file.name.includes(".") + ? file.name.split(".").pop() + : ""; + const title = file.name + .split(".") + .slice(0, -1) + .join(".") + .replace(/\s+/g, "_") + .slice(0, 20) || "untitled"; + const filename = ext ? `${title}.${ext}` : title; + const identifier = + mode === "public" + ? `pub-q-manager-${title.toLowerCase()}` + : mode === "private" + ? `pvt-q-manager-${uid.rnd()}` + : buildGroupQManagerIdentifier(groupId, !isPublicGroup, uid.rnd()); + return { filename, identifier }; + }; + + const executeMulti = async () => { + const promise = (async () => { + const selectedGroupId = normalizeGroupId(selectedGroup); + if (mode === "group" && !selectedGroupId) + throw new Error("Please select a group"); + if (!requestData?.service) throw new Error("Please select a service"); + const resolvedOwnerName = await resolvePreferredName(ownerName); + if (!resolvedOwnerName) throw new Error("Could not determine Qortal name"); + const selectedGroupInfo = + mode === "group" ? getGroupById(groups, selectedGroupId) : null; + if (mode === "group" && !selectedGroupInfo) { + throw new Error("Cannot find group"); + } + const isPublicGroup = selectedGroupInfo?.isOpen === true; setIsLoading(true); - let account = await qortalRequest({ + + // 1) build resources array + const resources = []; + const publishedItems = []; + for (const file of files) { + const { filename, identifier } = makeMeta( + file, + isPublicGroup, + selectedGroupId + ); + const shouldEncodeBeforePublish = + mode === "private" || (mode === "group" && !isPublicGroup); + const [data64, thumbnail] = + shouldEncodeBeforePublish + ? await Promise.all([ + fileToBase64(file), + isImageFile(file) + ? createImageThumbnailData64(file, file?.type || "image/png") + : Promise.resolve(null), + ]) + : ["", null]; + const mimeType = file?.type || "application/octet-stream"; + const sizeInBytes = Number(file?.size) || 0; + const publishedItem = { + file, + filename, + identifier, + mimeType, + sizeInBytes, + ...(thumbnail?.data64 + ? { + thumbnailData64: thumbnail.data64, + thumbnailMimeType: thumbnail.mimeType, + } + : {}), + }; + publishedItems.push(publishedItem); + + if (mode === "group") { + if (isPublicGroup) { + resources.push({ + name: resolvedOwnerName, + service: requestData.service, + identifier, + filename, + mimeType, + file, + }); + } else { + // group‐encrypt + const encrypted = await requestQortal({ + action: "ENCRYPT_QORTAL_GROUP_DATA", + data64, + groupId: selectedGroupId, + }); + resources.push({ + name: resolvedOwnerName, + service: requestData.service, + identifier, + data64: encrypted, + externalEncrypt: true, + }); + } + } else if (mode === "private") { + if (!data64) { + throw new Error("Unable to read file data for private encryption"); + } + // private‐encrypt + const encryptedResponse = await requestQortal({ + action: "ENCRYPT_DATA_WITH_SHARING_KEY", + data64, + }); + const { + data64: encrypted, + sharingKey, + publicKey, + } = normalizeEncryptedSharingKeyResponse(encryptedResponse); + resources.push({ + name: myName, + service: requestData.service, + identifier, + data64: encrypted, + externalEncrypt: true, + }); + publishedItem.sharingKey = sharingKey; + publishedItem.publicKey = accountPublicKey || publicKey; + } else { + // public + resources.push({ + name: myName, + service: requestData.service, + identifier, + filename, + mimeType, + file, // raw File object + }); + } + } + + // 2) send multi-publish request + const result = await requestQortal({ action: "PUBLISH_MULTIPLE_QDN_RESOURCES", - service: requestData?.service, - identifier: requestData?.identifier, - file, - data64: requestData?.data64 + name: resolvedOwnerName, + resources, }); - setResponseData(formatResponse(JSON.stringify(account))); - } catch (error) { - setResponseData(formatResponse(JSON.stringify(error))); - console.error(error); + if (!result || result?.error) { + throw new Error(result?.error || "Unable to publish the files"); + } + + // 3) update tree exactly like single publish + const indexOwner = accountAddress || myName; + for (const item of publishedItems) { + const groupEntry = + mode === "group" + ? { + group: selectedGroupId, + groupId: selectedGroupId, + groupName: + groups?.find((g) => Number(g.groupId) === selectedGroupId) + ?.groupName, + ...(isPublicGroup ? {} : { encryptionType: "group" }), + } + : {}; + + addNodeByPath(undefined, { + type: "file", + name: item.filename, + displayName: item.filename, + mimeType: item.mimeType, + ...(item.sizeInBytes !== undefined ? { sizeInBytes: item.sizeInBytes } : {}), + qortalName: myName, + identifier: item.identifier, + service: requestData.service, + ...(item.sharingKey ? { sharingKey: item.sharingKey } : {}), + ...(item.publicKey ? { publicKey: item.publicKey } : {}), + ...groupEntry, + }); + + if (mode === "private" || (mode === "group" && !isPublicGroup)) { + await upsertPrivateResourceIndexEntry(indexOwner, { + resourceKey: [ + accountAddress || myName || indexOwner || "", + requestData.service || "", + item.identifier || "", + selectedGroupId || 0, + ].join("|"), + qortalName: myName, + service: requestData.service, + identifier: item.identifier, + filename: item.filename, + displayName: item.filename, + mimeType: item.mimeType, + sizeInBytes: item.sizeInBytes, + encryptionType: mode === "group" ? "group" : "private", + ...(item.sharingKey ? { sharingKey: item.sharingKey } : {}), + ...(item.publicKey ? { publicKey: item.publicKey } : {}), + ...(item.thumbnailData64 + ? { + thumbnailData64: item.thumbnailData64, + thumbnailMimeType: item.thumbnailMimeType || "image/jpeg", + } + : {}), + ...(mode === "group" && !isPublicGroup + ? { + group: selectedGroupId, + groupId: selectedGroupId, + groupName: groups?.find((g) => Number(g.groupId) === selectedGroupId)?.groupName, + } + : {}), + }); + } + } + + setFiles([]); // clear selection + return result; + })(); + + await openToast(promise, { + loading: "Publishing files...", + success: "All files published!", + error: (e) => `Publish failed: ${e.message || e.error || e}`, + }); + + try { + const final = await promise; + setResponse(JSON.stringify(final, null, 2)); + } catch (e) { + setResponse(JSON.stringify(e, null, 2)); } finally { setIsLoading(false); } }; - const handleChange = (e) => { - setRequestData((prev) => { - return { - ...prev, - [e.target.name]: e.target.value, - }; - }); - }; return ( -
-
-
+ + + - - - - {file && ( - { - setFile(null) - }}>Remove file - )} - - -
-
- - -

Request

-
+
+ + -

Response

- {isLoading ? ( - - - - ) : ( - - )} + + + Click or drag files here to add more ({files.length}) +
+ + {files.length} file{files.length !== 1 ? "s" : ""} selected: + +
    + {files.map((f, i) => ( +
  • + {f.name}{" "} + + setFiles((prev) => prev.filter((_, idx) => idx !== i)) + } + > + Remove + +
  • + ))} +
+
+ +
+ ); }; diff --git a/src/actions/PUBLISH_QDN_RESOURCE.jsx b/src/actions/PUBLISH_QDN_RESOURCE.jsx index 8d529b3..956263e 100644 --- a/src/actions/PUBLISH_QDN_RESOURCE.jsx +++ b/src/actions/PUBLISH_QDN_RESOURCE.jsx @@ -8,19 +8,91 @@ import { Typography, styled, } from "@mui/material"; -import { DisplayCode } from "../components/DisplayCode"; -import { DisplayCodeResponse } from "../components/DisplayCodeResponse"; import ShortUniqueId from "short-unique-id"; import Button from "../components/Button"; import { useDropzone } from "react-dropzone"; import { privateServices, services } from "../constants"; -import { fileToBase64 } from "../utils"; -import toast from 'react-hot-toast'; +import { + createImageThumbnailData64, + buildGroupQManagerIdentifier, + fileToBase64, + getGroupById, + normalizeGroupId, + resolvePreferredName, +} from "../utils"; import { openToast } from "../components/openToast"; +import { requestQortal } from "../qapp/request"; +import { upsertPrivateResourceIndexEntry } from "../storage"; const uid = new ShortUniqueId({ length: 10 }); +const normalizeEncryptedSharingKeyResponse = (response) => { + if (response === null || response === undefined) { + return { + data64: "", + sharingKey: "", + publicKey: "", + }; + } + + if (typeof response === "string") { + const trimmed = response.trim(); + if (!trimmed) { + return { + data64: "", + sharingKey: "", + publicKey: "", + }; + } + + try { + const parsed = JSON.parse(trimmed); + if (parsed && typeof parsed === "object") { + return { + data64: + parsed?.data64 || + parsed?.data || + parsed?.encryptedData || + parsed?.payload || + "", + sharingKey: parsed?.key || parsed?.sharingKey || "", + publicKey: parsed?.publicKey || "", + raw: parsed, + }; + } + } catch (error) {} + + return { + data64: trimmed, + sharingKey: "", + publicKey: "", + raw: response, + }; + } + + if (typeof response === "object") { + return { + data64: + response?.data64 || + response?.data || + response?.encryptedData || + response?.payload || + "", + sharingKey: response?.key || response?.sharingKey || "", + publicKey: response?.publicKey || "", + raw: response, + }; + } + + return { + data64: String(response), + sharingKey: "", + publicKey: "", + raw: response, + }; +}; + export const Label = styled("label")( ({ theme }) => ` font-family: 'IBM Plex Sans', sans-serif; @@ -31,9 +103,11 @@ export const Label = styled("label")( ` ); -export const PUBLISH_QDN_RESOURCE = ({ addNodeByPath, myName, mode, existingFile, updateByPath , groups, selectedGroup}) => { +export const PUBLISH_QDN_RESOURCE = ({ addNodeByPath, myName, accountAddress, accountPublicKey, mode, existingFile, updateByPath , groups, selectedGroup}) => { const [requestData, setRequestData] = useState({ - service: existingFile?.service || mode === 'private' ? "DOCUMENT_PRIVATE" : "DOCUMENT" + service: + existingFile?.service || + (mode === "private" ? "DOCUMENT_PRIVATE" : "DOCUMENT"), }); const { getRootProps, getInputProps } = useDropzone({ @@ -47,7 +121,59 @@ export const PUBLISH_QDN_RESOURCE = ({ addNodeByPath, myName, mode, existingFile }); const [isLoading, setIsLoading] = useState(false); const [file, setFile] = useState(null); + const ownerName = typeof myName === "string" ? myName : ""; + const isImageFile = (candidate) => + typeof candidate?.type === "string" && + candidate.type.toLowerCase().startsWith("image/"); + const recordPrivateIndexEntry = async ({ + indexOwner, + identifier, + filename, + mimeType, + sizeInBytes, + encryptionType, + sharingKey, + publicKey, + thumbnailData64, + thumbnailMimeType, + groupId, + groupName, + service, + }) => { + if (!indexOwner) return; + await upsertPrivateResourceIndexEntry(indexOwner, { + resourceKey: [ + accountAddress || myName || indexOwner || "", + service || "", + identifier || "", + groupId || 0, + ].join("|"), + qortalName: myName, + service, + identifier, + filename, + displayName: filename, + mimeType, + sizeInBytes, + encryptionType, + ...(sharingKey ? { sharingKey } : {}), + ...(publicKey ? { publicKey } : {}), + ...(thumbnailData64 + ? { + thumbnailData64, + thumbnailMimeType: thumbnailMimeType || "image/jpeg", + } + : {}), + ...(groupId + ? { + group: groupId, + groupId, + groupName, + } + : {}), + }); + }; @@ -55,11 +181,16 @@ export const PUBLISH_QDN_RESOURCE = ({ addNodeByPath, myName, mode, existingFile const promise = (async () => { try { if (!file) throw new Error('Please select a file') - if(!selectedGroup) throw new Error('Please select a group') - const findGroup = groups?.find((group)=> group.groupId === selectedGroup) - if(!findGroup) throw new Error('Cannot find group') + if (!requestData?.service) throw new Error("Please select a service") + const resolvedOwnerName = await resolvePreferredName(ownerName) + if (!resolvedOwnerName) throw new Error("Could not determine Qortal name") + const selectedGroupId = normalizeGroupId(selectedGroup) + if(!selectedGroupId) throw new Error('Please select a group') + const findGroup = getGroupById(groups, selectedGroupId) + if(!findGroup) throw new Error('Cannot find group') + const isPublicGroup = findGroup?.isOpen === true setIsLoading(true); - + const fileExtension = file?.name?.includes(".") ? file.name.split(".").pop() : ""; const fileTitle = file?.name @@ -71,22 +202,45 @@ export const PUBLISH_QDN_RESOURCE = ({ addNodeByPath, myName, mode, existingFile const filename = fileExtension ? `${fileTitle}.${fileExtension}` : fileTitle; - const constructedIdentifier = existingFile?.identifier || `grp-q-manager-858-${uid.rnd()}`; - const base64File = await fileToBase64(file); - const encryptedData = await qortalRequest({ - action: "ENCRYPT_QORTAL_GROUP_DATA", - data64: base64File, - groupId: selectedGroup - }); + const constructedIdentifier = + existingFile?.identifier || + buildGroupQManagerIdentifier( + selectedGroupId, + !isPublicGroup, + uid.rnd() + ); + const [base64File, thumbnail] = isPublicGroup + ? ["", null] + : await Promise.all([ + fileToBase64(file), + isImageFile(file) + ? createImageThumbnailData64(file, file?.type || "image/png") + : Promise.resolve(null), + ]); + const encryptedData = isPublicGroup + ? "" + : await requestQortal({ + action: "ENCRYPT_QORTAL_GROUP_DATA", + data64: base64File, + groupId: selectedGroupId + }); - if(!encryptedData) throw new Error('Unable to encrypt data') + if(!isPublicGroup && !encryptedData) throw new Error('Unable to encrypt data') - let account = await qortalRequest({ + let account = await requestQortal({ action: "PUBLISH_QDN_RESOURCE", + name: resolvedOwnerName, service: existingFile?.service || requestData?.service, identifier: constructedIdentifier, - data64: encryptedData, - externalEncrypt: true, + ...(isPublicGroup + ? { + file, + filename, + } + : { + data64: encryptedData, + externalEncrypt: true, + }), }); @@ -94,27 +248,79 @@ export const PUBLISH_QDN_RESOURCE = ({ addNodeByPath, myName, mode, existingFile if (!!existingFile) { updateByPath({ ...existingFile, + name: filename, + displayName: filename, mimeType: file?.type, + sizeInBytes: file?.size, + ...(isPublicGroup ? {} : { encryptionType: "group" }), + group: selectedGroupId, + groupId: selectedGroupId, + groupName: findGroup?.groupName, }); setFile(""); + if (isPublicGroup) return true; + await recordPrivateIndexEntry({ + indexOwner: accountAddress || myName, + identifier: constructedIdentifier, + filename, + mimeType: file?.type, + sizeInBytes: file?.size, + encryptionType: "group", + groupId: selectedGroupId, + groupName: findGroup?.groupName, + service: requestData?.service, + ...(thumbnail?.data64 + ? { + thumbnailData64: thumbnail.data64, + thumbnailMimeType: thumbnail.mimeType, + } + : {}), + }); return true; // Success } - + addNodeByPath( undefined, { type: "file", name: filename, + displayName: filename, mimeType: file?.type, + sizeInBytes: file?.size, qortalName: myName, identifier: constructedIdentifier, service: requestData?.service, - group: selectedGroup, + ...(isPublicGroup ? {} : { encryptionType: "group" }), + group: selectedGroupId, + groupId: selectedGroupId, groupName: findGroup?.groupName }, undefined ); - + + setFile(""); + if (isPublicGroup) { + return true; + } + + await recordPrivateIndexEntry({ + indexOwner: accountAddress || myName, + identifier: constructedIdentifier, + filename, + mimeType: file?.type, + sizeInBytes: file?.size, + encryptionType: "group", + groupId: selectedGroupId, + groupName: findGroup?.groupName, + service: requestData?.service, + ...(thumbnail?.data64 + ? { + thumbnailData64: thumbnail.data64, + thumbnailMimeType: thumbnail.mimeType, + } + : {}), + }); + return true; // Success } else { @@ -140,6 +346,9 @@ export const PUBLISH_QDN_RESOURCE = ({ addNodeByPath, myName, mode, existingFile const promise = (async () => { try { if (!file) return; + if (!requestData?.service) throw new Error("Please select a service") + const resolvedOwnerName = await resolvePreferredName(ownerName) + if (!resolvedOwnerName) throw new Error("Could not determine Qortal name") setIsLoading(true); const fileExtension = file?.name?.includes(".") ? file.name.split(".").pop() : ""; @@ -153,45 +362,102 @@ export const PUBLISH_QDN_RESOURCE = ({ addNodeByPath, myName, mode, existingFile const filename = fileExtension ? `${fileTitle}.${fileExtension}` : fileTitle; - const constructedIdentifier = existingFile?.identifier || `p-q-manager-858-${uid.rnd()}`; - const base64File = await fileToBase64(file); - const encryptedData = await qortalRequest({ + const constructedIdentifier = existingFile?.identifier || `pvt-q-manager-${uid.rnd()}`; + const [base64File, thumbnail] = await Promise.all([ + fileToBase64(file), + isImageFile(file) + ? createImageThumbnailData64(file, file?.type || "image/png") + : Promise.resolve(null), + ]); + if (!base64File) { + throw new Error("Unable to read file data for private encryption"); + } + const encryptedResponse = await requestQortal({ action: "ENCRYPT_DATA_WITH_SHARING_KEY", data64: base64File, }); + const { data64: encryptedData, sharingKey, publicKey } = + normalizeEncryptedSharingKeyResponse(encryptedResponse); if(!encryptedData) throw new Error('Unable to encrypt data') - let account = await qortalRequest({ + let account = await requestQortal({ action: "PUBLISH_QDN_RESOURCE", + name: myName, service: existingFile?.service || requestData?.service, identifier: constructedIdentifier, data64: encryptedData, + externalEncrypt: true, }); if (account?.identifier) { if (!!existingFile) { updateByPath({ ...existingFile, + name: filename, + displayName: filename, mimeType: file?.type, + sizeInBytes: file?.size, + encryptionType: "private", + ...(sharingKey ? { sharingKey } : {}), + ...(accountPublicKey ? { publicKey: accountPublicKey } : {}), }); setFile(""); + await recordPrivateIndexEntry({ + indexOwner: accountAddress || myName, + identifier: constructedIdentifier, + filename, + mimeType: file?.type, + sizeInBytes: file?.size, + encryptionType: "private", + sharingKey, + publicKey: accountPublicKey || publicKey, + service: requestData?.service, + ...(thumbnail?.data64 + ? { + thumbnailData64: thumbnail.data64, + thumbnailMimeType: thumbnail.mimeType, + } + : {}), + }); return true; // Success } - + addNodeByPath( undefined, { type: "file", name: filename, + displayName: filename, mimeType: file?.type, + sizeInBytes: file?.size, qortalName: myName, identifier: constructedIdentifier, service: requestData?.service, + ...(sharingKey ? { sharingKey } : {}), + ...(accountPublicKey ? { publicKey: accountPublicKey } : {}), }, undefined ); - + + await recordPrivateIndexEntry({ + indexOwner: accountAddress || myName, + identifier: constructedIdentifier, + filename, + mimeType: file?.type, + sizeInBytes: file?.size, + encryptionType: "private", + sharingKey, + publicKey: accountPublicKey || publicKey, + service: requestData?.service, + ...(thumbnail?.data64 + ? { + thumbnailData64: thumbnail.data64, + thumbnailMimeType: thumbnail.mimeType, + } + : {}), + }); + return true; // Success } else { @@ -216,6 +482,13 @@ export const PUBLISH_QDN_RESOURCE = ({ addNodeByPath, myName, mode, existingFile setIsLoading(true); const promise = (async () => { + if (!requestData?.service) { + throw new Error("Please select a service"); + } + const resolvedOwnerName = await resolvePreferredName(ownerName) + if (!resolvedOwnerName) { + throw new Error("Could not determine Qortal name"); + } const fileExtension = file?.name?.includes(".") ? file.name.split(".").pop() : ""; @@ -232,8 +505,9 @@ export const PUBLISH_QDN_RESOURCE = ({ addNodeByPath, myName, mode, existingFile const constructedIdentifier = existingFile?.identifier || `q-manager-858-${uid.rnd()}`; - const account = await qortalRequest({ + const account = await requestQortal({ action: "PUBLISH_QDN_RESOURCE", + name: myName, service: existingFile?.service || requestData?.service, identifier: constructedIdentifier, file, @@ -244,7 +518,10 @@ export const PUBLISH_QDN_RESOURCE = ({ addNodeByPath, myName, mode, existingFile if (!!existingFile) { updateByPath({ ...existingFile, + name: filename, + displayName: filename, mimeType: file?.type, + sizeInBytes: file?.size, }); setFile(""); return; @@ -255,7 +532,9 @@ export const PUBLISH_QDN_RESOURCE = ({ addNodeByPath, myName, mode, existingFile { type: "file", name: filename, + displayName: filename, mimeType: file?.type, + sizeInBytes: file?.size, qortalName: myName, identifier: constructedIdentifier, service: requestData?.service, @@ -322,13 +601,15 @@ export const PUBLISH_QDN_RESOURCE = ({ addNodeByPath, myName, mode, existingFile MenuProps={{ PaperProps: { sx: { - backgroundColor: "#333333", // Background of the dropdown - color: "#ffffff", // Text color + backgroundColor: "#1f2530", + color: "#ffffff", + backgroundImage: "none", + maxHeight: 380, }, }, }} > - + No service selected {(mode === 'private' ? privateServices : services)?.map((service) => { diff --git a/src/actions/VOTE_ON_POLL.jsx b/src/actions/VOTE_ON_POLL.jsx index 08514f2..3ae9f11 100644 --- a/src/actions/VOTE_ON_POLL.jsx +++ b/src/actions/VOTE_ON_POLL.jsx @@ -5,6 +5,7 @@ import { DisplayCodeResponse } from "../components/DisplayCodeResponse"; import beautify from "js-beautify"; import Button from "../components/Button"; +import { requestQortal } from "../qapp/request"; export const Label = styled("label")( ({ theme }) => ` @@ -57,7 +58,7 @@ await qortalRequest({ const executeQortalRequest = async () => { try { setIsLoading(true) - let account = await qortalRequest({ + let account = await requestQortal({ action: "VOTE_ON_POLL", pollName: requestData?.pollName, optionIndex: requestData?.optionIndex, diff --git a/src/components/Button.jsx b/src/components/Button.jsx index 5b08a65..9c54c04 100644 --- a/src/components/Button.jsx +++ b/src/components/Button.jsx @@ -1,13 +1,14 @@ import React from "react"; import "./button.css"; -const Button = ({ name, onClick, bgColor }) => { +const Button = ({ name, onClick, bgColor, disabled = false }) => { return (
diff --git a/src/components/DisplayCodeResponse.tsx b/src/components/DisplayCodeResponse.tsx index 885561d..75ae0fe 100644 --- a/src/components/DisplayCodeResponse.tsx +++ b/src/components/DisplayCodeResponse.tsx @@ -2,11 +2,9 @@ import { useState } from "react"; import { Highlight, themes } from "prism-react-renderer"; import { Typography, Box, useTheme } from "@mui/material"; import { CodeWrapper, DisplayCodeResponsePre } from "./Common-styles"; +import React from "react"; -export const DisplayCodeResponse = ({ - codeBlock, - language = "javascript" -}) => { +export const DisplayCodeResponse = ({ codeBlock, language = "javascript" }) => { const theme = useTheme(); const [copyText, setCopyText] = useState("Copy"); @@ -14,9 +12,7 @@ export const DisplayCodeResponse = ({ return ( @@ -33,7 +29,7 @@ export const DisplayCodeResponse = ({ color: theme.palette.text.primary, borderTopRightRadius: "7px", borderTopLeftRadius: "7px", - marginBottom: "10px" + marginBottom: "10px", }} > RESPONSE @@ -51,7 +47,7 @@ export const DisplayCodeResponse = ({ userSelect: "none", opacity: "0.5", marginRight: "8px", - fontSize: "16px" + fontSize: "16px", }} > {i + 1} diff --git a/src/components/QortalSVG.tsx b/src/components/QortalSVG.tsx index 3314c9e..bd27693 100644 --- a/src/components/QortalSVG.tsx +++ b/src/components/QortalSVG.tsx @@ -1,10 +1,6 @@ +import React from "react"; -export const QortalSVG = ({ - color, - height, - width, - className -}) => { +export const QortalSVG = ({ color, height, width, className }) => { return ( { - return ( - - ); - }; \ No newline at end of file + return ( + + ); +}; diff --git a/src/components/button.css b/src/components/button.css index f3ca937..dda1bb5 100644 --- a/src/components/button.css +++ b/src/components/button.css @@ -25,3 +25,10 @@ .button:focus { outline: none; } + +.button:disabled { + opacity: 0.5; + cursor: not-allowed; + filter: none; + box-shadow: none; +} diff --git a/src/embedLink.ts b/src/embedLink.ts new file mode 100644 index 0000000..dc0306a --- /dev/null +++ b/src/embedLink.ts @@ -0,0 +1,623 @@ +import { + base64ToUint8Array, + isPrivateGroupQManagerIdentifier, + normalizeGroupId, +} from "./utils"; +import { + getPrivateResourceIndexEntry, + upsertPrivateResourceIndexEntry, +} from "./storage"; + +type RequestQortalFn = (payload: Record) => Promise; + +const safeLower = (value: unknown): string => { + if (typeof value === "string") return value.toLowerCase(); + if (value === undefined || value === null) return ""; + try { + return String(value).toLowerCase(); + } catch (error) { + return ""; + } +}; + +const isEncryptedResourceNode = (node: Record | null | undefined) => { + const service = safeLower(node?.service); + const encryptionType = safeLower(node?.encryptionType); + const identifier = safeLower(node?.identifier); + + return ( + encryptionType.includes("private") || + isPrivateGroupQManagerIdentifier(identifier) || + service.includes("_PRIVATE") || + identifier.startsWith("p-") || + identifier.startsWith("pvt-") + ); +}; + +export const inferEmbedTypeFromMimeType = (mimeType: unknown): string => { + const normalized = safeLower(mimeType); + return normalized.startsWith("image/") ? "IMAGE" : "ATTACHMENT"; +}; + +export const getDefaultEmbedFileName = ( + file: Record | null | undefined +): string => { + const candidates = [ + file?.displayName, + file?.filename, + file?.fileName, + file?.name, + file?.title, + file?.identifier, + ]; + + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim()) { + return candidate.trim(); + } + } + + return "Untitled"; +}; + +const getFileOwnerName = (file: Record | null | undefined): string => { + const candidates = [file?.qortalName, file?.name, file?.ownerName]; + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim()) { + return candidate.trim(); + } + } + return ""; +}; + +const parseMaybeJson = (value: string): Record | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + + try { + return JSON.parse(trimmed); + } catch (error) {} + + try { + const decoded = new TextDecoder("utf-8", { fatal: false }).decode( + base64ToUint8Array(trimmed) + ); + return JSON.parse(decoded); + } catch (error) {} + + return null; +}; + +const isValidSharingKeyValue = (value: unknown): boolean => { + if (typeof value !== "string") { + return false; + } + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + try { + return base64ToUint8Array(trimmed).length === 32; + } catch (error) { + return false; + } +}; + +const extractSharingKeyFromDecryptResponse = ( + value: unknown +): string => { + if (value === null || value === undefined) { + return ""; + } + + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return ""; + + const parsed = parseMaybeJson(trimmed); + if (parsed) { + return extractSharingKeyFromDecryptResponse(parsed); + } + + return ""; + } + + if (typeof value !== "object") { + return ""; + } + + const candidateSources = [ + (value as Record)?.key, + (value as Record)?.sharingKey, + (value as Record)?.data, + (value as Record)?.result, + (value as Record)?.payload, + (value as Record)?.content, + (value as Record)?.metadata?.key, + (value as Record)?.metadata?.sharingKey, + (value as Record)?.metadata?.data, + (value as Record)?.metadata?.result, + (value as Record)?.metadata?.payload, + (value as Record)?.metadata?.content, + ]; + + for (const candidate of candidateSources) { + if (typeof candidate !== "string") continue; + const trimmed = candidate.trim(); + if (!trimmed) continue; + + const parsed = parseMaybeJson(trimmed); + if ( + parsed && + typeof parsed.data === "string" && + typeof parsed.key === "string" && + Object.keys(parsed).length <= 3 && + isValidSharingKeyValue(parsed.key) + ) { + return parsed.key.trim(); + } + } + + const nestedSources = [ + (value as Record)?.data, + (value as Record)?.result, + (value as Record)?.payload, + (value as Record)?.content, + (value as Record)?.metadata, + ]; + + for (const nestedSource of nestedSources) { + if (!nestedSource || typeof nestedSource !== "object") continue; + const nested = extractSharingKeyFromDecryptResponse(nestedSource); + if (nested) return nested; + } + + return ""; +}; + +const normalizeBase64Payload = (value: unknown): string => { + if (value === null || value === undefined) { + return ""; + } + + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return ""; + + const parsed = parseMaybeJson(trimmed); + if ( + parsed && + typeof parsed.data === "string" && + typeof parsed.key === "string" && + Object.keys(parsed).length <= 3 && + isValidSharingKeyValue(parsed.key) + ) { + return parsed.data.trim(); + } + + return trimmed; + } + + if (typeof value !== "object") { + return ""; + } + + const candidateSources = [ + (value as Record)?.data64, + (value as Record)?.data, + (value as Record)?.encryptedData, + (value as Record)?.payload, + (value as Record)?.content, + (value as Record)?.result, + ]; + + for (const candidate of candidateSources) { + if (typeof candidate !== "string") continue; + const trimmed = candidate.trim(); + if (!trimmed) continue; + + const parsed = parseMaybeJson(trimmed); + if ( + parsed && + typeof parsed.data === "string" && + typeof parsed.key === "string" && + Object.keys(parsed).length <= 3 && + isValidSharingKeyValue(parsed.key) + ) { + return parsed.data.trim(); + } + + return trimmed; + } + + const nestedSources = [ + (value as Record)?.data, + (value as Record)?.result, + (value as Record)?.payload, + (value as Record)?.content, + ]; + + for (const nestedSource of nestedSources) { + if (!nestedSource || typeof nestedSource !== "object") continue; + const nested = normalizeBase64Payload(nestedSource); + if (nested) return nested; + } + + return ""; +}; + +const fetchEncryptedPrivateResourceBase64 = async (file: Record) => { + const service = typeof file?.service === "string" ? file.service : ""; + const identifier = typeof file?.identifier === "string" ? file.identifier : ""; + const qortalName = getFileOwnerName(file); + if (!service || !identifier || !qortalName) { + throw new Error("Could not determine encrypted resource fields"); + } + + const response = await fetch( + `/arbitrary/${encodeURIComponent(service)}/${encodeURIComponent( + qortalName + )}/${encodeURIComponent(identifier)}?encoding=base64` + ); + if (!response.ok) { + throw new Error("Could not fetch encrypted resource"); + } + + const encryptedData = await response.text(); + if (!encryptedData) { + throw new Error("Could not load encrypted resource"); + } + + return encryptedData; +}; + +const decryptPrivateResourceBase64 = async ({ + file, + requestQortal, + encryptedData, + accountPublicKey = "", +}: { + file: Record; + requestQortal: RequestQortalFn; + encryptedData: string; + accountPublicKey?: string; +}) => { + const sharingKey = typeof file?.sharingKey === "string" ? file.sharingKey : ""; + const publicKey = + typeof file?.publicKey === "string" && file.publicKey.trim() + ? file.publicKey.trim() + : accountPublicKey.trim(); + const attempts = [ + { + action: "DECRYPT_DATA_WITH_SHARING_KEY", + encryptedData, + data64: encryptedData, + ...(sharingKey ? { key: sharingKey } : {}), + ...(publicKey ? { publicKey } : {}), + }, + { + action: "DECRYPT_DATA", + encryptedData, + data64: encryptedData, + ...(publicKey ? { publicKey } : {}), + }, + { + action: "DECRYPT_DATA", + encryptedData, + data64: encryptedData, + }, + ]; + + for (const attempt of attempts) { + try { + const decryptedResponse = await requestQortal(attempt); + const plainData64 = normalizeBase64Payload(decryptedResponse); + if (plainData64) { + return plainData64; + } + } catch (error) {} + } + + throw new Error("Could not decrypt this private file"); +}; + +const persistPrivateResourceSharingKey = async ({ + file, + sharingKey, + accountAddress, + accountPublicKey = "", +}: { + file: Record; + sharingKey: string; + accountAddress?: string; + accountPublicKey?: string; +}) => { + const normalizedAccountAddress = typeof accountAddress === "string" ? accountAddress.trim() : ""; + if (!normalizedAccountAddress || !sharingKey) { + return; + } + + const service = getServiceName(file); + const identifier = typeof file?.identifier === "string" ? file.identifier.trim() : ""; + const qortalName = getFileOwnerName(file); + if (!service || !identifier || !qortalName) { + return; + } + + await upsertPrivateResourceIndexEntry(normalizedAccountAddress, { + resourceKey: [qortalName, service, identifier, file?.group || file?.groupId || 0].join("|"), + qortalName, + service, + identifier, + filename: getDefaultEmbedFileName(file), + displayName: getDefaultEmbedFileName(file), + mimeType: file?.mimeType || "application/octet-stream", + sizeInBytes: Number(file?.sizeInBytes || file?.size || 0) || 0, + encryptionType: file?.encryptionType || "private", + ...(accountPublicKey ? { publicKey: accountPublicKey } : {}), + sharingKey, + ...(file?.thumbnailData64 + ? { + thumbnailData64: file.thumbnailData64, + thumbnailMimeType: file.thumbnailMimeType || "image/jpeg", + } + : {}), + }); +}; + +const resolveKnownPrivateSharingKey = async ({ + file, + accountAddress, +}: { + file: Record; + accountAddress?: string; +}) => { + const directCandidates = [file?.sharingKey, file?.key]; + for (const candidate of directCandidates) { + if (isValidSharingKeyValue(candidate)) { + return candidate.trim(); + } + } + + const normalizedAccountAddress = + typeof accountAddress === "string" ? accountAddress.trim() : ""; + if (!normalizedAccountAddress) { + return ""; + } + + const resourceKey = + typeof file?.resourceKey === "string" && file.resourceKey.trim() + ? file.resourceKey.trim() + : typeof file?.entryKey === "string" && file.entryKey.trim() + ? file.entryKey.trim() + : [getFileOwnerName(file), getServiceName(file), file?.identifier || "", file?.group || file?.groupId || 0].join("|"); + + if (!resourceKey) { + return ""; + } + + const privateIndexEntry = await getPrivateResourceIndexEntry( + normalizedAccountAddress, + resourceKey + ); + const indexedKey = privateIndexEntry?.sharingKey || privateIndexEntry?.key || ""; + return isValidSharingKeyValue(indexedKey) ? indexedKey.trim() : ""; +}; + +const republishPrivateResourceWithSharingKey = async ({ + file, + requestQortal, + accountAddress, + accountPublicKey = "", +}: { + file: Record; + requestQortal: RequestQortalFn; + accountAddress?: string; + accountPublicKey?: string; +}) => { + const encryptedData = await fetchEncryptedPrivateResourceBase64(file); + const plainData64 = await decryptPrivateResourceBase64({ + file, + requestQortal, + encryptedData, + accountPublicKey, + }); + + const encryptedResponse = await requestQortal({ + action: "ENCRYPT_DATA_WITH_SHARING_KEY", + data64: plainData64, + }); + const normalizedEncrypted = normalizeEncryptedSharingKeyResponse( + encryptedResponse + ); + + if (!normalizedEncrypted.data64 || !normalizedEncrypted.sharingKey) { + throw new Error("Could not re-encrypt this private file"); + } + + const service = typeof file?.service === "string" ? file.service : ""; + const identifier = typeof file?.identifier === "string" ? file.identifier : ""; + const qortalName = getFileOwnerName(file); + if (!service || !identifier || !qortalName) { + throw new Error("Could not determine encrypted resource fields"); + } + + const publishResult = await requestQortal({ + action: "PUBLISH_QDN_RESOURCE", + name: qortalName, + service, + identifier, + data64: normalizedEncrypted.data64, + externalEncrypt: true, + }); + if (!publishResult?.identifier) { + throw new Error("Unable to republish this private file"); + } + + await persistPrivateResourceSharingKey({ + file, + sharingKey: normalizedEncrypted.sharingKey, + accountAddress, + accountPublicKey: normalizedEncrypted.publicKey || accountPublicKey, + }); + + return normalizedEncrypted.sharingKey; +}; + +const resolvePrivateSharingKey = async ({ + file, + requestQortal, + accountAddress, + accountPublicKey = "", +}: { + file: Record; + requestQortal: RequestQortalFn; + accountAddress?: string; + accountPublicKey?: string; +}) => { + const knownSharingKey = await resolveKnownPrivateSharingKey({ + file, + accountAddress, + }); + if (knownSharingKey) { + return knownSharingKey; + } + + const encryptedData = await fetchEncryptedPrivateResourceBase64(file); + const publicKey = + typeof file?.publicKey === "string" && file.publicKey.trim() + ? file.publicKey.trim() + : accountPublicKey.trim(); + + const decryptAttempts = [ + { + action: "DECRYPT_DATA", + encryptedData, + ...(publicKey ? { publicKey } : {}), + }, + { + action: "DECRYPT_DATA", + encryptedData, + }, + ]; + + for (const attempt of decryptAttempts) { + try { + const decryptedResponse = await requestQortal(attempt); + const resolvedKey = extractSharingKeyFromDecryptResponse( + decryptedResponse + ); + if (resolvedKey) { + return resolvedKey; + } + } catch (error) {} + } + + throw new Error("Could not determine the sharing key for this private file"); +}; + +export const copyEmbedLinkForFile = async ({ + file, + requestQortal, + selectedType, + customFileName, + accountAddress, + accountPublicKey, +}: { + file: Record; + requestQortal: RequestQortalFn; + selectedType?: string | number; + customFileName?: string; + accountAddress?: string; + accountPublicKey?: string; +}) => { + if (!file || typeof file !== "object") { + throw new Error("Please select a file"); + } + if (typeof requestQortal !== "function") { + throw new Error("Embed link request is unavailable"); + } + + const service = typeof file?.service === "string" ? file.service : ""; + const identifier = typeof file?.identifier === "string" ? file.identifier : ""; + const qortalName = getFileOwnerName(file); + if (!service || !identifier || !qortalName) { + throw new Error("Could not determine embed link fields"); + } + + const fileName = + typeof customFileName === "string" && customFileName.trim() + ? customFileName.trim() + : getDefaultEmbedFileName(file); + const type = + typeof selectedType === "string" && selectedType.trim() + ? selectedType.trim() + : inferEmbedTypeFromMimeType(file?.mimeType); + const groupId = normalizeGroupId(file?.groupId ?? file?.group); + const encrypted = isEncryptedResourceNode(file); + + if (groupId) { + await requestQortal({ + action: "CREATE_AND_COPY_EMBED_LINK", + type, + name: qortalName, + identifier, + service, + mimeType: file?.mimeType, + fileName, + groupId, + ...(encrypted ? { encryptionType: "group" } : {}), + }); + return true; + } + + if (!encrypted) { + await requestQortal({ + action: "CREATE_AND_COPY_EMBED_LINK", + type, + name: qortalName, + identifier, + service, + mimeType: file?.mimeType, + fileName, + }); + return true; + } + + let privateSharingKey = ""; + try { + privateSharingKey = await resolvePrivateSharingKey({ + file, + requestQortal, + accountAddress, + accountPublicKey, + }); + } catch (error) { + privateSharingKey = await republishPrivateResourceWithSharingKey({ + file, + requestQortal, + accountAddress, + accountPublicKey, + }); + } + + await persistPrivateResourceSharingKey({ + file, + sharingKey: privateSharingKey, + accountAddress, + accountPublicKey, + }); + + await requestQortal({ + action: "CREATE_AND_COPY_EMBED_LINK", + type, + name: qortalName, + identifier, + service, + encryptionType: "private", + key: privateSharingKey, + mimeType: file?.mimeType, + fileName, + }); + return true; +}; diff --git a/src/global.d.ts b/src/global.d.ts index 7874332..ab291c3 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1,51 +1,64 @@ -// src/global.d.ts -interface QortalRequestOptions { - action: string - name?: string - service?: string - data64?: string - title?: string - description?: string - category?: string - tags?: string[] - identifier?: string - address?: string - metaData?: string - encoding?: string - includeMetadata?: boolean - limit?: numebr - offset?: number - reverse?: boolean - resources?: any[] - filename?: string - list_name?: string - item?: string - items?: strings[] - tag1?: string - tag2?: string - tag3?: string - tag4?: string - tag5?: string - coin?: string - destinationAddress?: string - amount?: number - blob?: Blob - mimeType?: string - file?: File - encryptedData?: string - prefix?: boolean - exactMatchNames?: boolean -} - -declare function qortalRequest(options: QortalRequestOptions): Promise -declare function qortalRequestWithTimeout( - options: QortalRequestOptions, - time: number -): Promise +export {} declare global { + interface QortalRequestOptions { + action: string + name?: string + service?: string + data64?: string + title?: string + description?: string + category?: string + tags?: string[] + identifier?: string + address?: string + metaData?: string + encoding?: string + includeMetadata?: boolean + limit?: number + offset?: number + reverse?: boolean + resources?: any[] + filename?: string + list_name?: string + item?: string + items?: string[] + tag1?: string + tag2?: string + tag3?: string + tag4?: string + tag5?: string + coin?: string + destinationAddress?: string + amount?: number + blob?: Blob + mimeType?: string + file?: File + encryptedData?: string + prefix?: boolean + exactMatchNames?: boolean + groupId?: number + } + + function qortalRequest(options: QortalRequestOptions): Promise + function qortalRequestWithTimeout( + options: QortalRequestOptions, + time: number + ): Promise + interface Window { - _qdnBase: any // Replace 'any' with the appropriate type if you know it + _qdnBase: any _qdnTheme: string + qappCore?: { + request?: (options: QortalRequestOptions) => Promise + qortalRequest?: (options: QortalRequestOptions) => Promise + } + QAppCore?: { + request?: (options: QortalRequestOptions) => Promise + } + qapp?: { + request?: (options: QortalRequestOptions) => Promise + qortalRequest?: (options: QortalRequestOptions) => Promise + } } -} \ No newline at end of file +} diff --git a/src/index.css b/src/index.css index 0a52493..1913c52 100644 --- a/src/index.css +++ b/src/index.css @@ -24,6 +24,7 @@ font-weight: 400; color-scheme: light dark; color: rgba(255, 255, 255, 0.87); + background-color: rgb(39, 40, 44); font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; @@ -43,11 +44,17 @@ a:hover { body { margin: 0; - display: flex; - place-items: center; + display: block; min-width: 320px; min-height: 100vh; background-color: rgb(39, 40, 44); + overflow-x: hidden; +} + +html, body, #root { + width: 100%; + min-height: 100%; + background-color: rgb(39, 40, 44); } ::-webkit-scrollbar-track { @@ -121,7 +128,7 @@ p { @media (prefers-color-scheme: light) { :root { color: #213547; - background-color: #ffffff; + background-color: rgb(39, 40, 44); } a:hover { color: #747bff; diff --git a/src/qapp/request.ts b/src/qapp/request.ts new file mode 100644 index 0000000..f4ca263 --- /dev/null +++ b/src/qapp/request.ts @@ -0,0 +1,69 @@ +type RequestFn = (options: Record) => Promise + +const isFunction = (value: unknown): value is RequestFn => + typeof value === 'function' + +const getWindowRef = (): any => { + if (typeof window === 'undefined') return undefined + return window as any +} + +const getGlobalQortalRequest = (): RequestFn | null => { + try { + if (typeof qortalRequest === 'function') { + return qortalRequest as RequestFn + } + } catch (error) {} + return null +} + +const resolveProvider = (): { name: string; request: RequestFn } | null => { + const win = getWindowRef() + + const qappCoreRequest = + win?.qappCore?.request || + win?.QAppCore?.request || + win?.qappCore?.qortalRequest + if (isFunction(qappCoreRequest)) { + return { + name: 'qapp-core', + request: qappCoreRequest, + } + } + + const qappRequest = win?.qapp?.request || win?.qapp?.qortalRequest + if (isFunction(qappRequest)) { + return { + name: 'qapp', + request: qappRequest, + } + } + + const legacyRequest = getGlobalQortalRequest() + if (legacyRequest) { + return { + name: 'legacy-qortalRequest', + request: legacyRequest, + } + } + + return null +} + +export const getQortalRequestProvider = (): string => { + const provider = resolveProvider() + return provider?.name || 'none' +} + +export const requestQortal = async ( + options: Record +): Promise => { + const provider = resolveProvider() + if (!provider) { + throw new Error( + 'No Qortal request provider found (qapp-core, qapp, or qortalRequest)' + ) + } + return provider.request(options) +} + diff --git a/src/storage.ts b/src/storage.ts index d3a4cf0..63f950c 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,12 +1,158 @@ +// @ts-nocheck +import { + base64ToUint8Array, + parseGroupQManagerIdentifier, + objectToBase64, + resolvePreferredName, + uint8ArrayToObject, +} from "./utils"; +import { privateServices, services } from "./constants"; +import { requestQortal } from "./qapp/request"; + +const DB_NAME = "FileSystemDB"; +const DB_VERSION = 2; +const STORE_NAME = "fileSystemQManager"; +const PRIVATE_RESOURCE_INDEX_STORE = "privateResourceIndex"; +const LOCAL_STORAGE_PREFIX = "q-manager-filesystem-v1"; +const PRIVATE_INDEX_LOCAL_STORAGE_PREFIX = "q-manager-private-index-v1"; +const PRIVATE_INDEX_LOCAL_MAX_BYTES = Math.floor(4.75 * 1024 * 1024); + +const QDN_STRUCTURE_IDENTIFIER = "q-manager-filesystem-v1"; +const QDN_STRUCTURE_FILENAME = "q-manager-filesystem-v1.txt"; +const LEGACY_QDN_BACKUP_IDENTIFIER = "qmgr-db-backup"; +const STORAGE_RECORD_VERSION = 2; + +const getLocalStorageKey = (address) => `${LOCAL_STORAGE_PREFIX}:${address}`; + +const isValidFileSystemQManager = (data) => { + return ( + data && + typeof data === "object" && + Array.isArray(data.public) && + Array.isArray(data.private) && + data.group !== undefined + ); +}; + +const QDN_FILESYSTEM_IDENTIFIER = "q-manager-filesystem-v1"; + +export const getQdnFileSystemIdentifier = () => QDN_FILESYSTEM_IDENTIFIER; + +const isValidPrivateResourceIndex = (data) => { + return ( + data && + typeof data === "object" && + typeof data.entries === "object" && + data.entries !== null + ); +}; + +const getNow = () => Date.now(); + +const toStorageRecord = (fileSystemQManager, updatedAt = getNow()) => ({ + version: STORAGE_RECORD_VERSION, + updatedAt, + data: fileSystemQManager, +}); + +const toPrivateIndexRecord = (privateResourceIndex, updatedAt = getNow()) => ({ + version: 1, + updatedAt, + data: privateResourceIndex, +}); + +const parseFileSystemRecord = (raw) => { + if (!raw || typeof raw !== "object") return null; + + if (isValidFileSystemQManager(raw?.data)) { + return { + data: raw.data, + updatedAt: Number(raw.updatedAt) || 0, + }; + } + + if (isValidFileSystemQManager(raw)) { + return { + data: raw, + updatedAt: Number(raw.updatedAt || raw._updatedAt) || 0, + }; + } + + return null; +}; + +const parsePrivateIndexRecord = (raw) => { + if (!raw || typeof raw !== "object") return null; + + if (isValidPrivateResourceIndex(raw?.data)) { + return { + data: raw.data, + updatedAt: Number(raw.updatedAt) || 0, + }; + } + + if (isValidPrivateResourceIndex(raw)) { + return { + data: raw, + updatedAt: Number(raw.updatedAt || raw._updatedAt) || 0, + }; + } + + return null; +}; + +const getResourceField = (resource, keys) => { + for (const key of keys) { + if (resource?.[key] !== undefined && resource?.[key] !== null) { + return resource[key]; + } + } + return undefined; +}; + +const buildResourcePropertyPayloads = (resource) => { + const basePayload = { + action: "GET_QDN_RESOURCE_PROPERTIES", + service: resource?.service, + identifier: resource?.identifier, + }; + const ownerName = resource?.qortalName || resource?.name; + if (!ownerName) return [basePayload]; + return [ + { ...basePayload, name: ownerName }, + { ...basePayload, qortalName: ownerName }, + basePayload, + ]; +}; + +export const fetchQdnResourceProperties = async (resource) => { + if (!resource?.service || !resource?.identifier) return null; + if (typeof requestQortal !== "function") return null; + + const payloads = buildResourcePropertyPayloads(resource); + + for (const payload of payloads) { + try { + const response = await requestQortal(payload); + if (response === undefined || response === null) continue; + return response; + } catch (error) {} + } + + return null; +}; + const initializeDB = () => { return new Promise((resolve, reject) => { - const request = indexedDB.open("FileSystemDB", 1); + const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = (event) => { const db = event.target.result; - if (!db.objectStoreNames.contains("fileSystemQManager")) { - // Create object store with `address` as the keyPath - db.createObjectStore("fileSystemQManager", { keyPath: "address" }); + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: "address" }); + } + if (!db.objectStoreNames.contains(PRIVATE_RESOURCE_INDEX_STORE)) { + db.createObjectStore(PRIVATE_RESOURCE_INDEX_STORE, { keyPath: "name" }); } }; @@ -15,47 +161,748 @@ const initializeDB = () => { }); }; - +export const saveFileSystemQManagerToLocalStorage = ( + fileSystemQManager, + address, + updatedAt = getNow() +) => { + if (!address) return; + + try { + const key = getLocalStorageKey(address); + localStorage.setItem( + key, + JSON.stringify(toStorageRecord(fileSystemQManager, updatedAt)) + ); + } catch (error) { + console.error("Error saving fileSystemQManager to localStorage:", error); + } +}; + +const getFileSystemQManagerRecordFromLocalStorage = (address) => { + if (!address) return null; + + try { + const key = getLocalStorageKey(address); + const stored = localStorage.getItem(key); + if (!stored) return null; + const parsed = JSON.parse(stored); + return parseFileSystemRecord(parsed); + } catch (error) { + console.error("Error reading fileSystemQManager from localStorage:", error); + return null; + } +}; + +export const getFileSystemQManagerFromLocalStorage = (address) => { + const record = getFileSystemQManagerRecordFromLocalStorage(address); + return record?.data || null; +}; + +export const saveFileSystemQManagerToDB = async ( + fileSystemQManager, + address, + updatedAt = getNow() +) => { + if (!address) throw new Error("Address is required to save filesystem."); - export const saveFileSystemQManagerToDB = async ( fileSystemQManager, address) => { + try { + const db = await initializeDB(); + const transaction = db.transaction(STORE_NAME, "readwrite"); + const store = transaction.objectStore(STORE_NAME); + + store.put({ + address, + ...toStorageRecord(fileSystemQManager, updatedAt), + }); + + return new Promise((resolve) => { + transaction.oncomplete = () => resolve(true); + transaction.onerror = () => resolve(false); + }); + } catch (error) { + console.error("Error saving fileSystemQManager to IndexedDB:", error); + return false; + } +}; + +const getFileSystemQManagerRecordFromDB = async (address) => { + if (!address) return null; + + try { + const db = await initializeDB(); + const transaction = db.transaction(STORE_NAME, "readonly"); + const store = transaction.objectStore(STORE_NAME); + + return new Promise((resolve, reject) => { + const request = store.get(address); + + request.onsuccess = (event) => { + if (event.target.result) { + resolve(parseFileSystemRecord(event.target.result)); + } else { + resolve(null); + } + }; + request.onerror = (event) => reject(event.target.error); + }); + } catch (error) { + console.error("Error retrieving fileSystemQManager from IndexedDB:", error); + return null; + } +}; + +export const getFileSystemQManagerFromDB = async (address) => { + const record = await getFileSystemQManagerRecordFromDB(address); + return record?.data || null; +}; + +export const saveFileSystemQManagerEverywhere = async ( + fileSystemQManager, + address +) => { + if (!address) throw new Error("Address is required to save filesystem."); + const updatedAt = getNow(); + + const dbSaved = await saveFileSystemQManagerToDB( + fileSystemQManager, + address, + updatedAt + ); + + // Keep localStorage as a backup snapshot and fallback path. + if (dbSaved) { + saveFileSystemQManagerToLocalStorage(fileSystemQManager, address, updatedAt); + return { + updatedAt, + primary: "indexeddb", + fallbackUsed: false, + }; + } + + saveFileSystemQManagerToLocalStorage(fileSystemQManager, address, updatedAt); + return { + updatedAt, + primary: "localstorage", + fallbackUsed: true, + }; +}; + +const getPrivateIndexLocalStorageKey = (name) => + `${PRIVATE_INDEX_LOCAL_STORAGE_PREFIX}:${name}`; + +const getPrivateIndexRecordFromLocalStorage = (name, fallbackNames = []) => { + const lookupNames = [name, ...(Array.isArray(fallbackNames) ? fallbackNames : [])].filter( + Boolean + ); + if (lookupNames.length === 0) return null; + + for (const lookupName of lookupNames) { try { - const db = await initializeDB(); - const transaction = db.transaction("fileSystemQManager", "readwrite"); - const store = transaction.objectStore("fileSystemQManager"); - - // Save or update data for the specific address - store.put({ address, data: fileSystemQManager }); - - return new Promise((resolve, reject) => { - transaction.oncomplete = () => resolve(`FileSystemQManager for address ${address} saved successfully`); - transaction.onerror = (event) => reject(event.target.error); - }); + const key = getPrivateIndexLocalStorageKey(lookupName); + const stored = localStorage.getItem(key); + if (!stored) continue; + const parsed = JSON.parse(stored); + const record = parsePrivateIndexRecord(parsed); + if (record) return record; } catch (error) { - console.error("Error saving fileSystemQManager to IndexedDB:", error); + console.error("Error reading private index from localStorage:", error); } - }; - - export const getFileSystemQManagerFromDB = async (address) => { - try { - const db = await initializeDB(); - const transaction = db.transaction("fileSystemQManager", "readonly"); - const store = transaction.objectStore("fileSystemQManager"); - - return new Promise((resolve, reject) => { - const request = store.get(address); - + } + + return null; +}; + +export const getPrivateResourceIndexFromLocalStorage = (name, fallbackNames = []) => { + const record = getPrivateIndexRecordFromLocalStorage(name, fallbackNames); + return record?.data || null; +}; + +const savePrivateIndexToLocalStorage = ( + privateResourceIndex, + name, + updatedAt = getNow() +) => { + if (!name) return; + + try { + const serialized = JSON.stringify( + toPrivateIndexRecord(privateResourceIndex, updatedAt) + ); + if (serialized.length > PRIVATE_INDEX_LOCAL_MAX_BYTES) { + localStorage.removeItem(getPrivateIndexLocalStorageKey(name)); + return; + } + localStorage.setItem(getPrivateIndexLocalStorageKey(name), serialized); + } catch (error) { + console.error("Error saving private index to localStorage:", error); + } +}; + +export const savePrivateResourceIndexToDB = async ( + privateResourceIndex, + name, + updatedAt = getNow() +) => { + if (!name) throw new Error("Name is required to save private index."); + + try { + const db = await initializeDB(); + const transaction = db.transaction(PRIVATE_RESOURCE_INDEX_STORE, "readwrite"); + const store = transaction.objectStore(PRIVATE_RESOURCE_INDEX_STORE); + + store.put({ + name, + ...toPrivateIndexRecord(privateResourceIndex, updatedAt), + }); + + return new Promise((resolve) => { + transaction.oncomplete = () => resolve(true); + transaction.onerror = () => resolve(false); + }); + } catch (error) { + console.error("Error saving private index to IndexedDB:", error); + return false; + } +}; + +const getPrivateIndexRecordFromDB = async (name, fallbackNames = []) => { + const lookupNames = [name, ...(Array.isArray(fallbackNames) ? fallbackNames : [])].filter( + Boolean + ); + if (lookupNames.length === 0) return null; + + try { + const db = await initializeDB(); + + for (const lookupName of lookupNames) { + const transaction = db.transaction(PRIVATE_RESOURCE_INDEX_STORE, "readonly"); + const store = transaction.objectStore(PRIVATE_RESOURCE_INDEX_STORE); + const record = await new Promise((resolve, reject) => { + const request = store.get(lookupName); + request.onsuccess = (event) => { if (event.target.result) { - resolve(event.target.result.data); + resolve(parsePrivateIndexRecord(event.target.result)); } else { - resolve(null); // No data found for this address + resolve(null); } }; request.onerror = (event) => reject(event.target.error); }); - } catch (error) { - console.error("Error retrieving fileSystemQManager from IndexedDB:", error); + + if (record) return record; } + } catch (error) { + console.error("Error retrieving private index from IndexedDB:", error); + return null; + } + + return null; +}; + +export const getPrivateResourceIndexFromDB = async (name, fallbackNames = []) => { + const record = await getPrivateIndexRecordFromDB(name, fallbackNames); + return record?.data || null; +}; + +export const savePrivateResourceIndexEverywhere = async ( + privateResourceIndex, + name +) => { + if (!name) throw new Error("Name is required to save private index."); + const updatedAt = getNow(); + + const dbSaved = await savePrivateResourceIndexToDB( + privateResourceIndex, + name, + updatedAt + ); + + if (dbSaved) { + savePrivateIndexToLocalStorage(privateResourceIndex, name, updatedAt); + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("q-manager-private-index-changed", { + detail: { name, updatedAt, source: "indexeddb" }, + }) + ); + } + return { + updatedAt, + primary: "indexeddb", + fallbackUsed: false, + }; + } + + savePrivateIndexToLocalStorage(privateResourceIndex, name, updatedAt); + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("q-manager-private-index-changed", { + detail: { name, updatedAt, source: "localstorage" }, + }) + ); + } + return { + updatedAt, + primary: "localstorage", + fallbackUsed: true, + }; +}; + +export const getPersistedPrivateResourceIndex = async (name, fallbackNames = []) => { + if (!name) return null; + + const dbRecord = await getPrivateIndexRecordFromDB(name, fallbackNames); + const localRecord = getPrivateIndexRecordFromLocalStorage(name, fallbackNames); + + if (dbRecord?.data && localRecord?.data) { + const dbUpdatedAt = Number(dbRecord.updatedAt) || 0; + const localUpdatedAt = Number(localRecord.updatedAt) || 0; + + if (localUpdatedAt > dbUpdatedAt) { + await savePrivateResourceIndexToDB( + localRecord.data, + name, + localUpdatedAt || getNow() + ); + return localRecord.data; + } + + savePrivateIndexToLocalStorage(dbRecord.data, name, dbUpdatedAt || getNow()); + return dbRecord.data; + } + + if (dbRecord?.data) { + const synchronizedAt = Number(dbRecord.updatedAt) || getNow(); + savePrivateIndexToLocalStorage(dbRecord.data, name, synchronizedAt); + return dbRecord.data; + } + + if (localRecord?.data) { + const synchronizedAt = Number(localRecord.updatedAt) || getNow(); + await savePrivateResourceIndexToDB(localRecord.data, name, synchronizedAt); + return localRecord.data; + } + + return null; +}; + +export const upsertPrivateResourceIndexEntry = async (name, entry) => { + if (!name) throw new Error("Name is required to update private index."); + if (!entry || typeof entry !== "object") return null; + + const existingIndex = + (await getPersistedPrivateResourceIndex(name)) || { entries: {} }; + const nextEntries = { ...(existingIndex.entries || {}) }; + const key = + entry.resourceKey || + entry.entryKey || + entry.key || + [ + entry?.qortalName || name, + entry?.service || "", + entry?.identifier || "", + entry?.group || entry?.groupId || 0, + ].join("|"); + + nextEntries[key] = { + ...(nextEntries[key] || {}), + ...entry, + key, + updatedAt: getNow(), + }; + + const nextIndex = { + version: 1, + updatedAt: getNow(), + entries: nextEntries, + }; + + await savePrivateResourceIndexEverywhere(nextIndex, name); + return nextIndex; +}; + +export const getPrivateResourceIndexEntry = async (name, resourceKey) => { + if (!name || !resourceKey) return null; + const index = await getPersistedPrivateResourceIndex(name); + return index?.entries?.[resourceKey] || null; +}; + +export const getPersistedFileSystemQManager = async (address) => { + if (!address) return null; + + const dbRecord = await getFileSystemQManagerRecordFromDB(address); + const localRecord = getFileSystemQManagerRecordFromLocalStorage(address); + + if (dbRecord?.data && localRecord?.data) { + const dbUpdatedAt = Number(dbRecord.updatedAt) || 0; + const localUpdatedAt = Number(localRecord.updatedAt) || 0; + + // Prefer the newer snapshot. If equal/unknown, keep IndexedDB as source of truth. + if (localUpdatedAt > dbUpdatedAt) { + await saveFileSystemQManagerToDB( + localRecord.data, + address, + localUpdatedAt || getNow() + ); + return localRecord.data; + } + + saveFileSystemQManagerToLocalStorage( + dbRecord.data, + address, + dbUpdatedAt || getNow() + ); + return dbRecord.data; + } + + if (dbRecord?.data) { + const synchronizedAt = Number(dbRecord.updatedAt) || getNow(); + saveFileSystemQManagerToLocalStorage( + dbRecord.data, + address, + synchronizedAt + ); + return dbRecord.data; + } + + // IndexedDB unavailable/empty: fallback to local backup and heal DB opportunistically. + if (localRecord?.data) { + const synchronizedAt = Number(localRecord.updatedAt) || getNow(); + await saveFileSystemQManagerToDB(localRecord.data, address, synchronizedAt); + return localRecord.data; + } + + return null; +}; + +export const publishFileSystemQManagerToQDN = async ({ + fileSystemQManager, + privateResourceIndex, + activePublishName, +}) => { + if (!fileSystemQManager) { + throw new Error("No filesystem data available to publish"); + } + if (!activePublishName) { + throw new Error("Qortal name is required to publish filesystem"); + } + + // Always include the private index in the QDN backup so another node can + // load the complete local state (filesystem + private index) from QDN. + const payload = { + version: 1, + publishedAt: getNow(), + publishedBy: activePublishName, + fileSystem: { + public: fileSystemQManager.public, + private: fileSystemQManager.private, + group: fileSystemQManager.group || {}, + }, + privateResourceIndex: privateResourceIndex || { entries: {} }, }; - - \ No newline at end of file + const plainData64 = await objectToBase64(payload); + const encryptedData = await requestQortal({ + action: "ENCRYPT_DATA", + data64: plainData64, + }); + + if (!encryptedData) { + throw new Error("Failed to encrypt filesystem data"); + } + + return requestQortal({ + action: "PUBLISH_QDN_RESOURCE", + name: activePublishName, + service: "DOCUMENT_PRIVATE", + identifier: QDN_STRUCTURE_IDENTIFIER, + filename: QDN_STRUCTURE_FILENAME, + data64: encryptedData, + }); +}; + +export const importFileSystemQManagerFromQDN = async (name) => { + if (!name) { + throw new Error("Qortal name is required to import from QDN"); + } + + const response = await fetch( + `/arbitrary/DOCUMENT_PRIVATE/${name}/${QDN_STRUCTURE_IDENTIFIER}?encoding=base64` + ); + + if (!response.ok) { + throw new Error(`Could not fetch filesystem resource from QDN (${response.status})`); + } + + const encryptedData = await response.text(); + if (!encryptedData) { + throw new Error("No filesystem data found in QDN resource"); + } + + const decryptedData = await requestQortal({ + action: "DECRYPT_DATA", + encryptedData, + }); + + if (!decryptedData) { + throw new Error("Could not decrypt filesystem data"); + } + + const decryptedBytes = base64ToUint8Array(decryptedData); + const parsed = uint8ArrayToObject(decryptedBytes); + if (!parsed || typeof parsed !== "object") { + throw new Error("QDN filesystem data is invalid"); + } + + // Handle both new v1 structure (filesystem + private index) and legacy structure + if (parsed?.fileSystem) { + return { + public: parsed.fileSystem.public, + private: parsed.fileSystem.private, + group: parsed.fileSystem.group || {}, + ...(parsed?.privateResourceIndex ? { privateResourceIndex: parsed.privateResourceIndex } : {}), + _publishedAt: parsed.publishedAt, + _publishedBy: parsed.publishedBy, + }; + } + + // Legacy format - directly has public/private/group + if (isValidFileSystemQManager(parsed)) { + return { + public: parsed.public, + private: parsed.private, + group: parsed.group || {}, + }; + } + + throw new Error("QDN filesystem data is invalid"); +}; + +const normalizeResourceList = (payload) => { + if (Array.isArray(payload)) return payload; + if (Array.isArray(payload?.resources)) return payload.resources; + if (Array.isArray(payload?.data)) return payload.data; + return []; +}; + +const fetchResourcesFromEndpoint = async (url) => { + try { + const response = await fetch(url); + if (!response.ok) return []; + const json = await response.json(); + return normalizeResourceList(json); + } catch (error) { + return []; + } +}; + +const isDeleteTombstoneResource = (resource) => { + const rawSize = getResourceField(resource, [ + "size", + "sizeInBytes", + "dataSize", + "createdSize", + "totalSize", + ]); + const numericSize = Number(rawSize); + if (!Number.isFinite(numericSize)) return false; + return numericSize <= 1; +}; + +const normalizeDiscoveredResource = (resource, ownerName) => { + const identifier = getResourceField(resource, [ + "identifier", + "id", + "resourceId", + ]); + if (!identifier || typeof identifier !== "string") return null; + + const identifierLower = identifier.toLowerCase(); + if (!identifierLower.includes("q-manager")) return null; + if (identifier === QDN_STRUCTURE_IDENTIFIER) return null; + if (identifier === LEGACY_QDN_BACKUP_IDENTIFIER) return null; + if (isDeleteTombstoneResource(resource)) return null; + + const groupIdentifierInfo = parseGroupQManagerIdentifier(identifier); + + const service = getResourceField(resource, ["service", "serviceName"]); + if (!service || typeof service !== "string") return null; + + const qortalName = getResourceField(resource, ["name", "qortalName"]) || ownerName; + if (!qortalName) return null; + + const filename = getResourceField(resource, ["filename", "fileName"]); + const title = getResourceField(resource, ["title"]); + const mimeType = getResourceField(resource, [ + "mimeType", + "mime", + "contentType", + "mediaType", + ]); + const encryptionType = getResourceField(resource, [ + "encryptionType", + "encryption", + "type", + ]); + const groupId = getResourceField(resource, ["groupId", "group", "groupid"]); + const rawSize = getResourceField(resource, [ + "sizeInBytes", + "size", + "dataSize", + "createdSize", + "totalSize", + ]); + const parsedSize = Number(rawSize); + const sizeInBytes = + Number.isFinite(parsedSize) && parsedSize >= 0 ? parsedSize : undefined; + + return { + type: "file", + name: filename || title || identifier, + displayName: filename || title || identifier, + ...(filename ? { filename } : {}), + ...(title ? { title } : {}), + identifier, + service, + qortalName, + mimeType: mimeType || "application/octet-stream", + ...(groupIdentifierInfo + ? groupIdentifierInfo.isPrivateGroup + ? { encryptionType: "group" } + : {} + : encryptionType + ? { encryptionType } + : identifierLower.startsWith("p-") || identifierLower.startsWith("pvt-") + ? { encryptionType: "private" } + : {}), + groupId: Number(groupIdentifierInfo?.groupId) || Number(groupId) || 0, + ...(sizeInBytes !== undefined ? { sizeInBytes } : {}), + }; +}; + +const hydrateDiscoveredResourceFromProperties = async (resource) => { + if (!resource?.service || !resource?.identifier) return resource; + if ( + resource?.filename && + resource?.mimeType && + resource?.sizeInBytes !== undefined && + resource?.encryptionType + ) { + return resource; + } + const properties = await fetchQdnResourceProperties(resource); + + if (!properties || typeof properties !== "object") return resource; + + const filename = getResourceField(properties, ["filename", "fileName"]); + const mimeType = getResourceField(properties, [ + "mimeType", + "mime", + "contentType", + "mediaType", + ]); + const encryptionType = getResourceField(properties, [ + "encryptionType", + "encryption", + "type", + ]); + const rawSize = getResourceField(properties, [ + "sizeInBytes", + "size", + "dataSize", + "createdSize", + "totalSize", + ]); + const parsedSize = Number(rawSize); + const sizeInBytes = + Number.isFinite(parsedSize) && parsedSize >= 0 ? parsedSize : undefined; + + const next = { ...resource }; + if (filename) { + next.filename = filename; + if (!next.displayName || next.displayName === next.identifier) { + next.displayName = filename; + } + if (!next.name || next.name === next.identifier) { + next.name = filename; + } + } + if (mimeType && (!next.mimeType || next.mimeType === "application/octet-stream")) { + next.mimeType = mimeType; + } + if (sizeInBytes !== undefined && next.sizeInBytes === undefined) { + next.sizeInBytes = sizeInBytes; + } + if (encryptionType && !next.encryptionType) { + next.encryptionType = encryptionType; + } + + return next; +}; + +export const discoverQManagerResourcesByName = async (name) => { + if (!name) { + throw new Error("Qortal name is required to discover published resources"); + } + + const encodedName = encodeURIComponent(name); + const discoveredMap = new Map(); + + const sharedQuery = "reverse=true&limit=0&offset=0&includemetadata=true"; + const broadEndpoints = [ + `/arbitrary/resources/search?name=${encodedName}&${sharedQuery}`, + `/arbitrary/resources?name=${encodedName}&${sharedQuery}`, + ]; + + const broadResults = await Promise.all( + broadEndpoints.map(fetchResourcesFromEndpoint) + ); + + for (const list of broadResults) { + for (const resource of list) { + const normalized = normalizeDiscoveredResource(resource, name); + if (!normalized) continue; + const key = `${normalized.service}|${normalized.identifier}|${normalized.qortalName}`; + discoveredMap.set(key, normalized); + } + } + + if (discoveredMap.size === 0) { + const allServices = Array.from( + new Set([...services, ...privateServices].map((item) => item.name)) + ); + + const serviceResults = await Promise.all( + allServices.map((service) => + fetchResourcesFromEndpoint( + `/arbitrary/resources/search?name=${encodedName}&service=${encodeURIComponent( + service + )}&${sharedQuery}` + ) + ) + ); + + for (const list of serviceResults) { + for (const resource of list) { + const normalized = normalizeDiscoveredResource(resource, name); + if (!normalized) continue; + const key = `${normalized.service}|${normalized.identifier}|${normalized.qortalName}`; + discoveredMap.set(key, normalized); + } + } + } + + const discoveredResources = Array.from(discoveredMap.values()); + if (discoveredResources.length === 0) { + return discoveredResources; + } + + const hydratedResources = await Promise.all( + discoveredResources.map((resource) => + hydrateDiscoveredResourceFromProperties(resource) + ) + ); + + return hydratedResources; +}; diff --git a/src/utils.ts b/src/utils.ts index 88b97b4..0aa60a2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,15 +1,12 @@ -export function objectToBase64(obj: Object) { - // Step 1: Convert the object to a JSON string +export function objectToBase64(obj: unknown): Promise { const jsonString = JSON.stringify(obj) - // Step 2: Create a Blob from the JSON string const blob = new Blob([jsonString], { type: 'application/json' }) - // Step 3: Create a FileReader to read the Blob as a base64-encoded string - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onloadend = () => { - if (typeof reader.result === 'string') { - // Remove 'data:application/json;base64,' prefix - const base64 = reader.result.replace( + + return new Promise((resolve, reject) => { + const localReader = new FileReader() + localReader.onloadend = () => { + if (typeof localReader.result === 'string') { + const base64 = localReader.result.replace( 'data:application/json;base64,', '' ) @@ -18,76 +15,266 @@ export function objectToBase64(obj: Object) { reject(new Error('Failed to read the Blob as a base64-encoded string')) } } - reader.onerror = () => { - reject(reader.error) + localReader.onerror = () => { + reject(localReader.error ?? new Error('Failed to read the file')) + } + localReader.readAsDataURL(blob) + }) +} + +export function base64ToUint8Array(base64: string): Uint8Array { + const binaryString = atob(base64) + const len = binaryString.length + const bytes = new Uint8Array(len) + + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + + return bytes +} + +export function base64ToBlob( + base64: string, + mimeType = 'application/octet-stream' +): Blob { + return new Blob([base64ToUint8Array(base64)], { type: mimeType }) +} + +export function normalizeGroupId(value: unknown): number | null { + const parsed = Number(value) + return Number.isFinite(parsed) && parsed > 0 ? parsed : null +} + +export function getGroupById( + groups: Array> | null | undefined, + groupId: unknown +): Record | null { + const normalizedGroupId = normalizeGroupId(groupId) + if (!normalizedGroupId || !Array.isArray(groups)) return null + + return ( + groups.find((group) => { + return normalizeGroupId(group?.groupId) === normalizedGroupId + }) || null + ) +} + +export function isGroupOpen( + groups: Array> | null | undefined, + groupId: unknown +): boolean { + const group = getGroupById(groups, groupId) + return group?.isOpen === true +} + +export function parseGroupQManagerIdentifier(identifier: unknown): { + groupId: number | null + isPrivateGroup: boolean + isPublicGroup: boolean +} | null { + if (typeof identifier !== 'string') return null + const trimmed = identifier.trim() + if (!trimmed) return null + + const modernMatch = /^grp-q-manager_(0|1)_group_(\d+)(?:_|$)/i.exec(trimmed) + if (modernMatch) { + const groupId = normalizeGroupId(modernMatch[2]) + if (!groupId) return null + return { + groupId, + isPrivateGroup: modernMatch[1] === '0', + isPublicGroup: modernMatch[1] === '1', + } + } + + const legacyMatch = /^(grp|gpub)-(\d+)-q-manager/i.exec(trimmed) + if (legacyMatch) { + const groupId = normalizeGroupId(legacyMatch[2]) + if (!groupId) return null + return { + groupId, + isPrivateGroup: legacyMatch[1].toLowerCase() === 'grp', + isPublicGroup: legacyMatch[1].toLowerCase() === 'gpub', } - reader.readAsDataURL(blob) + } + + return null +} + +export function buildGroupQManagerIdentifier( + groupId: unknown, + isPrivateGroup: boolean, + suffix: string +): string { + const normalizedGroupId = normalizeGroupId(groupId) + if (!normalizedGroupId) { + throw new Error('Please select a group') + } + + const safeSuffix = typeof suffix === 'string' && suffix.trim() ? suffix.trim() : `${Date.now()}` + return `grp-q-manager_${isPrivateGroup ? 0 : 1}_group_${normalizedGroupId}_${safeSuffix}` +} + +export function isPrivateGroupQManagerIdentifier(identifier: unknown): boolean { + return parseGroupQManagerIdentifier(identifier)?.isPrivateGroup === true +} + +export function isPublicGroupQManagerIdentifier(identifier: unknown): boolean { + return parseGroupQManagerIdentifier(identifier)?.isPublicGroup === true +} + +const loadImageFromUrl = (url: string): Promise => { + return new Promise((resolve, reject) => { + const image = new Image() + image.onload = () => resolve(image) + image.onerror = () => + reject(new Error('Failed to load image data for thumbnail generation')) + image.src = url }) } -export function base64ToUint8Array(base64: string) { - const binaryString = atob(base64) - const len = binaryString.length - const bytes = new Uint8Array(len) +const fitThumbnailDimensions = ( + width: number, + height: number, + maxWidth: number, + maxHeight: number +) => { + if (!Number.isFinite(width) || width <= 0) width = 1 + if (!Number.isFinite(height) || height <= 0) height = 1 + const scale = Math.min(maxWidth / width, maxHeight / height, 1) + return { + width: Math.max(1, Math.round(width * scale)), + height: Math.max(1, Math.round(height * scale)), + } +} + +export const createImageThumbnailData64 = async ( + source: Blob | string, + sourceMimeType = 'image/png', + options?: { + maxWidth?: number + maxHeight?: number + outputMimeType?: string + quality?: number + backgroundColor?: string + } +): Promise< + | { + data64: string + mimeType: string + width: number + height: number + } + | null +> => { + if (typeof document === 'undefined' || typeof URL === 'undefined') return null + + const { + maxWidth = 160, + maxHeight = 160, + outputMimeType = 'image/jpeg', + quality = 0.82, + backgroundColor = '#ffffff', + } = options || {} - for (let i = 0; i < len; i++) { - bytes[i] = binaryString.charCodeAt(i) - } + const blob = + typeof source === 'string' ? base64ToBlob(source, sourceMimeType) : source + if (!blob) return null - return bytes - } + let objectUrl = '' + try { + objectUrl = URL.createObjectURL(blob) + const image = await loadImageFromUrl(objectUrl) + const sourceWidth = Number(image.naturalWidth || image.width || 1) + const sourceHeight = Number(image.naturalHeight || image.height || 1) + const { width, height } = fitThumbnailDimensions( + sourceWidth, + sourceHeight, + maxWidth, + maxHeight + ) - export function uint8ArrayToObject(uint8Array: Uint8Array) { - // Decode the byte array using TextDecoder - const decoder = new TextDecoder() - const jsonString = decoder.decode(uint8Array) + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height - // Convert the JSON string back into an object - const obj = JSON.parse(jsonString) + const context = canvas.getContext('2d') + if (!context) return null - return obj - } + if (outputMimeType === 'image/jpeg') { + context.fillStyle = backgroundColor + context.fillRect(0, 0, width, height) + } + context.drawImage(image, 0, 0, width, height) + + const dataUrl = canvas.toDataURL(outputMimeType, quality) + const commaIndex = dataUrl.indexOf(',') + return { + data64: commaIndex >= 0 ? dataUrl.slice(commaIndex + 1) : '', + mimeType: outputMimeType, + width, + height, + } + } catch (error) { + return null + } finally { + if (objectUrl) { + URL.revokeObjectURL(objectUrl) + } + } +} - export const handleImportClick = async () => { - const fileInput = document.createElement('input'); - fileInput.type = 'file'; - fileInput.accept = '.base64,.txt'; +export function uint8ArrayToObject(uint8Array: Uint8Array): T { + const decoder = new TextDecoder() + const jsonString = decoder.decode(uint8Array) + return JSON.parse(jsonString) as T +} - // Create a promise to handle file selection and reading synchronously - return await new Promise((resolve, reject) => { - fileInput.onchange = () => { - const file = fileInput.files[0]; - if (!file) { - reject(new Error('No file selected')); - return; - } +export const handleImportClick = async (): Promise => { + const fileInput = document.createElement('input') + fileInput.type = 'file' + fileInput.accept = '.base64,.txt' - const reader = new FileReader(); - reader.onload = (e) => { - resolve(e.target.result); // Resolve with the file content - }; - reader.onerror = () => { - reject(new Error('Error reading file')); - }; + return new Promise((resolve, reject) => { + fileInput.onchange = () => { + const file = fileInput.files?.[0] + if (!file) { + reject(new Error('No file selected')) + return + } - reader.readAsText(file); // Read the file as text (Base64 string) - }; + const localReader = new FileReader() + localReader.onload = () => { + if (typeof localReader.result === 'string') { + resolve(localReader.result) + } else { + reject(new Error('Invalid file content')) + } + } + localReader.onerror = () => { + reject(localReader.error ?? new Error('Error reading file')) + } - // Trigger the file input dialog - fileInput.click(); - }); + localReader.readAsText(file) + } - } + fileInput.click() + }) +} +class Semaphore { + private count: number + private waiting: Array<() => void> - class Semaphore { - constructor(count) { + constructor(count: number) { this.count = count this.waiting = [] } - acquire() { - return new Promise(resolve => { + + acquire(): Promise { + return new Promise(resolve => { if (this.count > 0) { this.count-- resolve() @@ -96,43 +283,128 @@ export function base64ToUint8Array(base64: string) { } }) } - release() { + + release(): void { if (this.waiting.length > 0) { const resolve = this.waiting.shift() - resolve() + if (resolve) resolve() } else { this.count++ } } } - let semaphore = new Semaphore(1) -let reader = new FileReader() +const semaphore = new Semaphore(1) +let reader: FileReader | null = new FileReader() -export const fileToBase64 = (file) => new Promise(async (resolve, reject) => { - if (!reader) { - reader = new FileReader() - } +export const fileToBase64 = async (file: Blob): Promise => { await semaphore.acquire() - reader.readAsDataURL(file) - reader.onload = () => { - const dataUrl = reader.result - if (typeof dataUrl === "string") { - const base64String = dataUrl.split(',')[1] - reader.onload = null - reader.onerror = null - resolve(base64String) - } else { + + try { + if (!reader) { + reader = new FileReader() + } + + const currentReader = reader as FileReader + + return await new Promise((resolve, reject) => { + currentReader.onload = () => { + const dataUrl = currentReader.result + if (typeof dataUrl === 'string') { + const base64String = dataUrl.split(',')[1] + if (base64String === undefined) { + reject(new Error('Invalid data URL')) + return + } + resolve(base64String) + } else { + reject(new Error('Invalid data URL')) + } + } + + currentReader.onerror = () => { + reject(currentReader.error ?? new Error('Failed to read file')) + } + + currentReader.readAsDataURL(file) + }) + } finally { + if (reader) { reader.onload = null reader.onerror = null - reject(new Error('Invalid data URL')) } semaphore.release() } - reader.onerror = (error) => { - reader.onload = null - reader.onerror = null - reject(error) - semaphore.release() +} + +const extractNameString = (value: unknown): string => { + if (typeof value === 'string') { + return value.trim() + } + if (value && typeof value === 'object') { + const entry = value as Record + if (typeof entry.name === 'string') return entry.name.trim() + if (typeof entry.primaryName === 'string') return entry.primaryName.trim() + } + return '' +} + +const extractPrimaryName = (payload: unknown): string => { + if (Array.isArray(payload)) { + for (const entry of payload) { + const candidate = extractNameString(entry) + if (candidate) return candidate + } + return '' + } + return extractNameString(payload) +} + +export const resolvePreferredName = async ( + candidateName?: string, + candidateAddress?: string +): Promise => { + if (typeof candidateName === 'string' && candidateName.trim()) { + return candidateName.trim() } -}) \ No newline at end of file + + let resolvedAddress = + typeof candidateAddress === 'string' && candidateAddress.trim() + ? candidateAddress.trim() + : '' + + try { + const primary = await qortalRequest({ + action: 'GET_PRIMARY_NAME', + ...(resolvedAddress ? { address: resolvedAddress } : {}), + }) + const primaryName = extractPrimaryName(primary) + if (primaryName) return primaryName + } catch (error) {} + + if (!resolvedAddress) { + try { + const account = await qortalRequest({ action: 'GET_USER_ACCOUNT' }) + if (account?.address && typeof account.address === 'string') { + resolvedAddress = account.address + } + } catch (error) {} + } + + if (!resolvedAddress) return '' + + try { + const accountNames = await qortalRequest({ + action: 'GET_ACCOUNT_NAMES', + address: resolvedAddress, + }) + if (Array.isArray(accountNames)) { + for (const entry of accountNames) { + const name = extractNameString(entry) + if (name) return name + } + } + } catch (error) {} + + return '' +} diff --git a/vite.config.js b/vite.config.js index 2ff9c76..8eaec8b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -4,5 +4,16 @@ import react from "@vitejs/plugin-react"; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], - base: "", + base: "./", + server: { + watch: { + usePolling: true, + ignored: [ + '**/node_modules/**', + '**/.git/**', + '**/.vscode/**', + '**/dist/**' + ], + }, + }, });