Compare commits

..

1 Commits

Author SHA1 Message Date
Ammar Ahmed
bb42a2e1a4 ci: skip testflight 2024-08-08 10:19:10 +05:00
1474 changed files with 175432 additions and 155028 deletions

View File

@@ -10,12 +10,11 @@ const authors = readFileSync("AUTHORS", "utf-8");
const isAuthor = authors.includes(`<${authorEmail}>`);
const SCOPES = [
// for full list of scopes + details see: https://github.com/streetwriters/notesnook/blob/master/CONTRIBUTING.md#commit-guidelines
// for full list of scopes + details see: https://github.com/streetwriters/notesnook-private/blob/master/CONTRIBUTING.md#commit-guidelines
"mobile",
"web",
"vericrypt",
"monograph",
"desktop",
"crypto",
"editor",
@@ -35,9 +34,7 @@ const SCOPES = [
"common",
"global",
"docs",
"themebuilder",
"intl",
"webclipper"
"themebuilder"
];
module.exports = {

View File

@@ -1,6 +1,8 @@
name: Bug Report
description: Are you facing a bug or a crash in Notesnook?
type: Bug
labels: ["Type: Bug", "Status: Pending"]
assignees:
- thecodrr
body:
- type: markdown
attributes:

View File

@@ -1,5 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Notesnook Discord Community
url: https://go.notesnook.com/discord
url: https://discord.gg/6mHHyncHJE
about: Reach out to us directly & discuss your issues, suggestions & other feedback!

View File

@@ -1,6 +1,6 @@
name: Feature request
description: Suggest a feature you are missing in Notesnook
type: Feature
labels: ["Type: Feature Request", "Status: Pending"]
body:
- type: markdown
attributes:

View File

@@ -14,8 +14,6 @@ runs:
apps/mobile/package-lock.json
apps/desktop/package-lock.json
apps/web/package-lock.json
apps/monograph/package-lock.json
apps/theme-builder/package-lock.json
extensions/web-clipper/package-lock.json
packages/core/package-lock.json
packages/crypto/package-lock.json
@@ -26,4 +24,3 @@ runs:
packages/logger/package-lock.json
packages/streamable-fs/package-lock.json
packages/theme/package-lock.json
packages/intl/package-lock.json

View File

@@ -1,100 +0,0 @@
name: e2e-android
on: workflow_dispatch
jobs:
e2e-android:
runs-on: ubuntu-latest
env:
API_LEVEL: 34
ARCH: x86_64
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Free Disk Space
uses: jlumbroso/free-disk-space@main
with:
tool-cache: false
android: false
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Install node modules
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=mobile
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
with:
gradle-version: wrapper
cache-read-only: false
- name: Use specific Java version for the builds
uses: joschi/setup-jdk@v2
with:
java-version: "17"
architecture: "x64"
- name: Install Detox CLI
run: npm install detox-cli --global
- name: Detox build
run: |
yarn build:android
ls apps/mobile/native/android/app/build/outputs/apk
ls apps/mobile/native/android/app/build/outputs/apk/release
- name: Get device name
id: device
run: |
AVD_NAME=$(node -p "require('./apps/mobile/native/.detoxrc.js').devices.emulator.device.avdName")
echo "AVD_NAME=$AVD_NAME" >> $GITHUB_OUTPUT
- name: AVD cache
uses: actions/cache@v4
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: ${{ steps.device.outputs.AVD_NAME }}
- name: create AVD and generate snapshot for caching
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ env.API_LEVEL }}
arch: ${{ env.ARCH }}
avd-name: ${{ steps.device.outputs.AVD_NAME }}
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: echo "Generated AVD snapshot for caching."
- name: Detox test
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ env.API_LEVEL }}
arch: ${{ env.ARCH }}
avd-name: ${{ steps.device.outputs.AVD_NAME }}
disable-animations: true
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
force-avd-creation: false
script: yarn test:android --headless --record-logs failing --record-videos failing --take-screenshots failing
- name: Upload artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: detox-artifacts
path: artifacts
retention-days: 14

View File

@@ -4,7 +4,7 @@ on: workflow_dispatch
jobs:
build:
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
timeout-minutes: 40
env:
CMAKE_C_COMPILER_LAUNCHER: ccache
@@ -22,7 +22,7 @@ jobs:
- name: Use specific Java version for the builds
uses: joschi/setup-jdk@v2
with:
java-version: "17"
java-version: "11"
architecture: "x64"
- name: Install node modules
@@ -58,7 +58,7 @@ jobs:
ccache -p
- name: Cache gradle
uses: actions/cache@v3
uses: actions/cache@v2
with:
path: |
~/.gradle/caches
@@ -69,6 +69,8 @@ jobs:
run: |
sed -i -e 's/arguments/arguments "-DCMAKE_CXX_COMPILER_LAUNCHER=ccache",/g' apps/mobile/node_modules/react-native-mmkv-storage/android/build.gradle
sed -i -e 's/arguments/arguments "-DCMAKE_CXX_COMPILER_LAUNCHER=ccache",/g' apps/mobile/node_modules/react-native-reanimated/android/build.gradle
sed -i -e 's/defaultConfig {/ndkVersion safeExtGet('ndkVersion', "25.2.9519653")\n defaultConfig {/g' apps/mobile/node_modules/react-native-reanimated//android/build.gradle
sed -i -e 's/defaultConfig {/ndkVersion safeExtGet('ndkVersion', "25.2.9519653")\n defaultConfig {/g' apps/mobile/node_modules/react-native-mmkv-storage/android/build.gradle
- name: CCache Stats Before Build
run: ccache -sv
@@ -127,7 +129,7 @@ jobs:
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
packageName: com.streetwriters.notesnook
releaseFiles: ${{steps.sign_app.outputs.signedReleaseFile}}
track: beta
track: alpha
status: completed
whatsNewDirectory: apps/mobile/native/android/releasenotes/
@@ -136,7 +138,7 @@ jobs:
with:
draft: true
tag_name: ${{ steps.package-version.outputs.current-version}}-beta-android
name: Notesnook Android v${{ steps.package-version.outputs.current-version}}
name: Notesnook Android v${{ steps.package-version.outputs.current-version}} Beta
repository: streetwriters/notesnook
token: ${{ secrets.GITHUB_TOKEN }}
files: |
@@ -144,7 +146,6 @@ jobs:
apps/mobile/native/android/app/build/outputs/apk/release/notesnook-armeabi-v7a.apk
apps/mobile/native/android/app/build/outputs/apk/release/notesnook-x86.apk
apps/mobile/native/android/app/build/outputs/apk/release/notesnook-x86_64.apk
${{steps.sign_app.outputs.signedReleaseFile}}
- name: Upload sourcemaps
uses: actions/upload-artifact@v4

View File

@@ -4,7 +4,7 @@ on: workflow_dispatch
jobs:
build:
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
timeout-minutes: 40
env:
CMAKE_C_COMPILER_LAUNCHER: ccache
@@ -22,7 +22,7 @@ jobs:
- name: Use specific Java version for the builds
uses: joschi/setup-jdk@v2
with:
java-version: "17"
java-version: "11"
architecture: "x64"
- name: Install node modules
@@ -58,7 +58,7 @@ jobs:
ccache -p
- name: Cache gradle
uses: actions/cache@v3
uses: actions/cache@v2
with:
path: |
~/.gradle/caches
@@ -69,6 +69,8 @@ jobs:
run: |
sed -i -e 's/arguments/arguments "-DCMAKE_CXX_COMPILER_LAUNCHER=ccache",/g' apps/mobile/node_modules/react-native-mmkv-storage/android/build.gradle
sed -i -e 's/arguments/arguments "-DCMAKE_CXX_COMPILER_LAUNCHER=ccache",/g' apps/mobile/node_modules/react-native-reanimated/android/build.gradle
sed -i -e 's/defaultConfig {/ndkVersion safeExtGet('ndkVersion', "25.2.9519653")\n defaultConfig {/g' apps/mobile/node_modules/react-native-reanimated//android/build.gradle
sed -i -e 's/defaultConfig {/ndkVersion safeExtGet('ndkVersion', "25.2.9519653")\n defaultConfig {/g' apps/mobile/node_modules/react-native-mmkv-storage/android/build.gradle
- name: CCache Stats Before Build
run: ccache -sv

View File

@@ -18,10 +18,10 @@ on:
jobs:
test:
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -33,22 +33,14 @@ on:
required: true
default: true
description: "Build for macOS?"
release-track:
type: choice
required: true
default: stable
description: "Select the release track"
options:
- stable
- beta
jobs:
build:
name: Build
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -57,30 +49,14 @@ jobs:
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=web
npm run bootstrap -- --scope=desktop
- name: Install sqlite-better-trigram for all arch
run: |
npm i --cpu arm64 sqlite-better-trigram
npm i --cpu x64 sqlite-better-trigram
working-directory: ./apps/desktop
- name: Setup environment
run: |
echo "NX_CLOUD_ACCESS_TOKEN=${{ secrets.NX_CLOUD_ACCESS_TOKEN }}" >> $GITHUB_ENV
- name: Generate desktop build (stable)
if: ${{ inputs.release-track == 'stable' }}
- name: Generate desktop build
run: npx nx build:desktop @notesnook/web
- name: Generate desktop build (beta)
if: ${{ inputs.release-track == 'beta' }}
run: BETA=true npx nx build:desktop @notesnook/web
- name: Build desktop bundle
working-directory: ./apps/desktop
run: npm run bundle
- name: Archive build artifact
uses: actions/upload-artifact@v4
with:
@@ -95,8 +71,7 @@ jobs:
- name: Package desktop build
run: |
cp -r ./apps/web/build ./apps/desktop/
zip -r ./notesnook_build_v${{ steps.app_metadata.outputs.app_version }}.zip ./apps/desktop/build/
zip -r ./notesnook_build_v${{ steps.app_metadata.outputs.app_version }}.zip ./apps/web/build/
echo "Build folder archived to ./notesnook_build_v${{ steps.app_metadata.outputs.app_version }}.zip"
- name: Upload desktop build
@@ -107,28 +82,6 @@ jobs:
name: Notesnook Desktop v${{ steps.app_metadata.outputs.app_version }}
tag_name: v${{ steps.app_metadata.outputs.app_version }}
files: ./notesnook_build_v${{ steps.app_metadata.outputs.app_version }}.zip
prerelease: ${{ inputs.release-track == 'beta' }}
target_commitish: ${{ github.ref }}
- name: Generate flatpak sources
if: inputs.publish-github && inputs.build-linux
run: |
sudo apt-get update
sudo apt-get install -y --quiet flatpak flatpak-builder pipx
git clone https://github.com/flatpak/flatpak-builder-tools.git /tmp/flatpak-builder-tools
cd /tmp/flatpak-builder-tools/node
pipx install .
cd -
node scripts/generate-sources.mjs
- name: Upload flatpak sources
uses: softprops/action-gh-release@v1
if: inputs.publish-github && inputs.build-linux
with:
draft: true
name: Notesnook Desktop v${{ steps.app_metadata.outputs.app_version }}
tag_name: v${{ steps.app_metadata.outputs.app_version }}
files: ./generated-sources.json
prerelease: ${{ endsWith(steps.app_metadata.outputs.app_version, '-beta') }}
build-macos:
@@ -139,7 +92,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -183,12 +136,6 @@ jobs:
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=desktop
- name: Install sqlite-better-trigram for all arch
run: |
npm i --cpu arm64 sqlite-better-trigram
npm i --cpu x64 sqlite-better-trigram
working-directory: ./apps/desktop
- name: Install provisioning profile
run: echo "${{ secrets.MAC_PROVISIONING_PROFILE }}" | base64 --decode > embedded.provisionprofile
working-directory: ./apps/desktop
@@ -200,7 +147,7 @@ jobs:
CSC_KEY_PASSWORD: ${{ secrets.mac_certs_password }}
run: |
npx nx run release --project @notesnook/desktop -- --variant=mas
yarn electron-builder --config=electron-builder.config.js --mac mas --universal -p never
yarn electron-builder --mac mas --universal -p never
working-directory: ./apps/desktop
- name: Build zip and dmg
@@ -214,9 +161,9 @@ jobs:
run: |
npx nx run release --project @notesnook/desktop
if [ ${{ inputs.publish-github }} == true ]; then
yarn electron-builder --config=electron-builder.config.js --mac zip dmg --arm64 --x64 -p always
yarn electron-builder --mac zip dmg --arm64 --x64 -p always
else
yarn electron-builder --config=electron-builder.config.js --mac zip dmg --arm64 --x64 -p never
yarn electron-builder --mac zip dmg --arm64 --x64 -p never
fi
working-directory: ./apps/desktop
@@ -233,15 +180,15 @@ jobs:
xcrun altool --upload-package $package -t osx --apiKey $API_KEY_ID --apiIssuer $API_KEY_ISSUER_ID --apple-id ${{ steps.app_metadata.outputs.apple_app_id }} --bundle-id ${{ steps.app_metadata.outputs.app_bundle_id }} --bundle-short-version-string ${{ steps.app_metadata.outputs.app_version }} --bundle-version ${{ steps.app_metadata.outputs.bundle_version }}
working-directory: ./apps/desktop
build-linux-x64:
name: Build for Linux x64
build-linux:
name: Build for Linux
needs: build
if: inputs.build-linux
runs-on: ubuntu-22.04
steps:
- name: Check out Git repository
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -251,7 +198,7 @@ jobs:
run: echo "SNAPCRAFT_STORE_CREDENTIALS=${{ secrets.snapcraft_token }}" >> $GITHUB_ENV
- name: Install Snapcraft
uses: samuelmeuli/action-snapcraft@v3
uses: samuelmeuli/action-snapcraft@v1
if: inputs.publish-snap
- name: Download build
@@ -265,11 +212,6 @@ jobs:
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=desktop
- name: Install sqlite-better-trigram for all arch
run: |
npm i --cpu x64 sqlite-better-trigram
working-directory: ./apps/desktop
- name: Build Electron wrapper
run: npx nx run release --project @notesnook/desktop
working-directory: ./apps/desktop
@@ -277,7 +219,7 @@ jobs:
- name: Build snap
if: inputs.publish-snap
run: |
yarn electron-builder --config=electron-builder.config.js --linux snap:x64 -p never
yarn electron-builder --linux snap:x64 -p never
working-directory: ./apps/desktop
- name: Build AppImage
@@ -285,86 +227,18 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ ${{ inputs.publish-github }} == true ]; then
yarn electron-builder --config=electron-builder.config.js --linux AppImage:x64 -p always
yarn electron-builder --linux AppImage:x64 AppImage:arm64 -p always
else
yarn electron-builder --config=electron-builder.config.js --linux AppImage:x64 -p never
yarn electron-builder --linux AppImage:x64 AppImage:arm64 -p never
fi
working-directory: ./apps/desktop
- name: Publish on Snapcraft
if: inputs.publish-snap
run: |
if [ ${{ inputs.release-track }} == 'beta' ]; then
snapcraft upload --release=beta ./output/notesnook_linux_amd64.snap
else
snapcraft upload --release=stable ./output/notesnook_linux_amd64.snap
fi
snapcraft upload --release=stable ./output/notesnook_linux_amd64.snap
working-directory: ./apps/desktop
build-linux-arm64:
name: Build for Linux arm64
needs: build
if: inputs.build-linux
runs-on: ubuntu-22.04-arm
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
# - name: Setup Snapcraft Auth
# if: inputs.publish-snap
# run: echo "SNAPCRAFT_STORE_CREDENTIALS=${{ secrets.snapcraft_token }}" >> $GITHUB_ENV
# - name: Install Snapcraft
# uses: samuelmeuli/action-snapcraft@v1
# if: inputs.publish-snap
- name: Download build
uses: actions/download-artifact@v4
with:
name: build
path: ./apps/web/build
- name: Install packages
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=desktop
- name: Install sqlite-better-trigram for all arch
run: |
npm i --cpu arm64 sqlite-better-trigram
working-directory: ./apps/desktop
- name: Build Electron wrapper
run: npx nx run release --project @notesnook/desktop
working-directory: ./apps/desktop
# - name: Build snap
# if: inputs.publish-snap
# run: |
# yarn electron-builder --config=electron-builder.config.js --linux snap:arm64 -p never
# working-directory: ./apps/desktop
- name: Build AppImage
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ ${{ inputs.publish-github }} == true ]; then
yarn electron-builder --config=electron-builder.config.js --linux AppImage:arm64 -p always
else
yarn electron-builder --config=electron-builder.config.js --linux AppImage:arm64 -p never
fi
working-directory: ./apps/desktop
# - name: Publish on Snapcraft
# if: inputs.publish-snap
# run: |
# snapcraft upload --release=stable ./output/notesnook_linux_arm64.snap
# working-directory: ./apps/desktop
build-windows:
name: Build for Windows
needs: build
@@ -373,7 +247,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Setup .NET Core SDK
uses: actions/setup-dotnet@v2
@@ -398,12 +272,6 @@ jobs:
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=desktop
- name: Install sqlite-better-trigram for all arch
run: |
npm i --cpu arm64 sqlite-better-trigram
npm i --cpu x64 sqlite-better-trigram
working-directory: ./apps/desktop
- name: Build
run: npx nx run release --project @notesnook/desktop
@@ -415,8 +283,8 @@ jobs:
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
run: |
if ($${{ inputs.publish-github }} -eq $true) {
yarn electron-builder --config=electron-builder.config.js --win --publish always
yarn electron-builder --win --publish always
} else {
yarn electron-builder --config=electron-builder.config.js --win --publish never
yarn electron-builder --win --publish never
}
working-directory: ./apps/desktop

View File

@@ -1,229 +0,0 @@
name: Test @notesnook/desktop
on:
workflow_dispatch:
push:
branches:
- "master"
paths:
- "app/desktop/**"
# re-run workflow if workflow file changes
- ".github/workflows/desktop.tests.yml"
pull_request:
jobs:
build:
name: Build
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Install packages
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=web
- name: Setup environment
run: |
echo "NX_CLOUD_ACCESS_TOKEN=${{ secrets.NX_CLOUD_ACCESS_TOKEN }}" >> $GITHUB_ENV
- name: Generate desktop build
run: npx nx build:desktop @notesnook/web
- name: Archive build artifact
uses: actions/upload-artifact@v4
with:
name: build
path: apps/web/build/**/*
test-macos-x64:
name: Test macOS x64
needs: build
runs-on: macos-13
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Download build
uses: actions/download-artifact@v4
with:
name: build
path: ./apps/web/build
- name: Install packages
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=desktop
- name: Build Electron wrapper
run: npx nx run release --project @notesnook/desktop
working-directory: ./apps/desktop
- name: Build app
run: |
yarn electron-builder --config=electron-builder.config.js --mac --dir --x64
working-directory: ./apps/desktop
- name: Run tests x64
run: npm run test
working-directory: ./apps/desktop
- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: test-results-macos-x64
path: apps/desktop/test-results
retention-days: 5
test-macos:
name: Test macOS
needs: build
runs-on: macos-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Download build
uses: actions/download-artifact@v4
with:
name: build
path: ./apps/web/build
- name: Install packages
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=desktop
- name: Build Electron wrapper
run: npx nx run release --project @notesnook/desktop
working-directory: ./apps/desktop
- name: Build app
run: |
yarn electron-builder --config=electron-builder.config.js --mac --dir --arm64
working-directory: ./apps/desktop
- name: Run tests arm64
run: npm run test
working-directory: ./apps/desktop
- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: test-results-macos
path: apps/desktop/test-results
retention-days: 5
test-linux:
name: Test for Linux
needs: build
runs-on: ubuntu-22.04
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Download build
uses: actions/download-artifact@v4
with:
name: build
path: ./apps/web/build
- name: Install packages
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=desktop
- name: Install sqlite-better-trigram for all arch
run: |
npm i --cpu arm64 sqlite-better-trigram
npm i --cpu x64 sqlite-better-trigram
working-directory: ./apps/desktop
- name: Build Electron wrapper
run: npx nx run release --project @notesnook/desktop
working-directory: ./apps/desktop
- name: Build app
run: |
yarn electron-builder --config=electron-builder.config.js --linux --dir --arm64 --x64
working-directory: ./apps/desktop
- name: Run tests
run: xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- npm run test
working-directory: ./apps/desktop
- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: test-results-linux
path: apps/desktop/test-results
retention-days: 5
test-windows:
name: Test for Windows
needs: build
runs-on: windows-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Download build
uses: actions/download-artifact@v4
with:
name: build
path: ./apps/web/build
- name: Install packages
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=desktop
- name: Install sqlite-better-trigram for all arch
run: |
npm i --cpu arm64 sqlite-better-trigram
npm i --cpu x64 sqlite-better-trigram
working-directory: ./apps/desktop
- name: Build Electron wrapper
run: npx nx run release --project @notesnook/desktop
working-directory: ./apps/desktop
- name: Build app
run: |
npx cross-env NOTESNOOK_STAGING=true yarn electron-builder --config=electron-builder.config.js --win --dir --arm64 --x64
working-directory: ./apps/desktop
- name: Run tests
run: npm run test
working-directory: ./apps/desktop
- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: test-results-win
path: apps/desktop/test-results
retention-days: 5

View File

@@ -19,10 +19,10 @@ on:
jobs:
build:
name: Build
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -1,6 +1,6 @@
name: Publish Notesnook Help
on:
on:
workflow_dispatch:
push:
branches:
@@ -11,10 +11,10 @@ on:
jobs:
build:
name: Build
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
with:
toolchain: stable

View File

@@ -4,21 +4,16 @@ on: workflow_dispatch
jobs:
build:
runs-on: macos-14
runs-on: macos-13
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v2
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: "16.1"
- name: Install node modules
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
@@ -52,17 +47,14 @@ jobs:
ccache -p
- name: Cache Pods
uses: actions/cache@v3
uses: actions/cache@v2
id: pods-cache
with:
path: apps/mobile/native/ios/Pods
key: ${{ runner.os }}-pods-${{ hashFiles('apps/mobile/native/ios/Podfile.lock') }}
- name: Install Pods
run: |
cd apps/mobile/native/ios
bundle install
RCT_NEW_ARCH_ENABLED=0 bundle exec pod install
run: npm run prepare:ios
- name: CCache Stats Before Build
run: ccache -sv
@@ -97,13 +89,13 @@ jobs:
- name: CCache Stats After Build
run: ccache -sv
- name: "Upload app to TestFlight"
uses: apple-actions/upload-testflight-build@v1
with:
app-path: Notesnook.ipa
issuer-id: ${{ secrets.API_KEY_ISSUER_ID }}
api-key-id: ${{ secrets.API_KEY_ID }}
api-private-key: ${{ secrets.API_KEY }}
# - name: "Upload app to TestFlight"
# uses: apple-actions/upload-testflight-build@v1
# with:
# app-path: Notesnook.ipa
# issuer-id: ${{ secrets.API_KEY_ISSUER_ID }}
# api-key-id: ${{ secrets.API_KEY_ID }}
# api-private-key: ${{ secrets.API_KEY }}
- name: Upload Notesnook.ipa to Github
uses: actions/upload-artifact@v4

View File

@@ -1,89 +0,0 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# GitHub recommends pinning actions to a commit SHA.
# To get a newer version, you will need to update the SHA.
# You can also reference a tag or branch, but the action may change without warning.
name: Publish @notesnook/monograph
on: workflow_dispatch
jobs:
push_to_registry:
name: Push Docker image to Docker Hub
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
attestations: write
id-token: write
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Install packages
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=monograph
- name: Collect package metadata
id: package_metadata
working-directory: ./apps/monograph
run: |
echo ::set-output name=app_version::$(cat package.json | jq -r .version)
- name: Generate build
run: npx nx build @notesnook/monograph
# Setup Buildx
- name: Docker Setup Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
ecr: auto
logout: true
# Pull previous image from docker hub to use it as cache to improve the image build time.
- name: docker pull cache image
continue-on-error: true
run: docker pull streetwriters/monograph:latest
# Setup QEMU
# - name: Set up QEMU
# uses: docker/setup-qemu-action@v2
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: streetwriters/monograph
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v6
with:
context: apps/monograph
file: apps/monograph/Dockerfile
push: true
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8
tags: streetwriters/monograph:${{ steps.package_metadata.outputs.app_version }},streetwriters/monograph:latest
cache-from: streetwriters/monograph:latest
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
with:
subject-name: index.docker.io/streetwriters/monograph
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true

View File

@@ -5,10 +5,10 @@ on: workflow_dispatch
jobs:
build:
name: Build
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -5,10 +5,10 @@ on: workflow_dispatch
jobs:
build:
name: Build
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -1,44 +0,0 @@
name: Benchmark @notesnook/web
on:
workflow_dispatch:
push:
branches:
- "master"
paths:
- "apps/web/**"
# re-run workflow if workflow file changes
- ".github/workflows/web.benchmarks.yml"
pull_request:
jobs:
bench:
name: Bench
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Install packages
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=web
- name: Setup environment
run: |
echo "NX_CLOUD_ACCESS_TOKEN=${{ secrets.NX_CLOUD_ACCESS_TOKEN }}" >> $GITHUB_ENV
- name: Build
run: npm run build:web
- name: Install Playwright Browsers
run: npx playwright install chromium --with-deps
working-directory: apps/web
- name: Run benchmarks
id: benchmark
run: node __bench__/app.bench.mjs
working-directory: apps/web

View File

@@ -1,24 +1,14 @@
name: Publish @notesnook/web
on:
workflow_dispatch:
inputs:
release-track:
type: choice
required: true
default: stable
description: "Select the release track"
options:
- stable
- beta
on: workflow_dispatch
jobs:
build:
name: Build
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -34,20 +24,8 @@ jobs:
echo "CLOUDFLARE_ACCOUNT_ID=${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" >> $GITHUB_ENV
echo "CLOUDFLARE_API_TOKEN=${{ secrets.CLOUDFLARE_API_TOKEN }}" >> $GITHUB_ENV
- name: Generate build (stable)
if: ${{ inputs.release-track == 'stable' }}
- name: Generate build
run: npm run build:web
- name: Publish to Cloudflare Pages (stable)
if: ${{ inputs.release-track == 'stable' }}
working-directory: ./apps/web
run: npx --yes wrangler pages deploy --project-name=notesnook-app ./build/
- name: Generate build (beta)
if: ${{ inputs.release-track == 'beta' }}
run: npm run build:beta:web
- name: Publish to Cloudflare Pages (beta)
if: ${{ inputs.release-track == 'beta' }}
working-directory: ./apps/web
run: npx --yes wrangler pages deploy --branch=beta --project-name=notesnook-app-beta ./build/
- name: Publish to Cloudflare Pages
run: npx --yes wrangler pages deploy --project-name=notesnook-app ./apps/web/build/

View File

@@ -19,10 +19,10 @@ on:
jobs:
build:
name: Build
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -49,11 +49,11 @@ jobs:
name: 🧪 Test (${{ matrix.shard }}/${{ strategy.job-total }})
strategy:
matrix:
shard: [1, 2, 3, 4, 5]
shard: [1, 2, 3, 4]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Download build
uses: actions/download-artifact@v4

1
.gitignore vendored
View File

@@ -11,4 +11,3 @@ nx-cloud.env
site
node_modules.backup
.nx
/*.patch

View File

@@ -66,7 +66,7 @@ We take all queries, issues and bug reports that you might have. Feel free to as
## Additional Resources
- [Migrating & Importing your data from other apps — Importer](https://help.notesnook.com/importing-notes)
- [Migrating & Importing your data from other apps — Importer](https://importer.notesnook.com/)
- [Privacy policy](https://notesnook.com/privacy) & [Terms of service](https://notesnook.com/terms)
- [Verify Notesnook encryption claims yourself — Vericrypt](https://vericrypt.notesnook.com/)
- [Why Notesnook requires an email address?](https://blog.notesnook.com/why-notesnook-requires-an-email-address/)

View File

@@ -2,6 +2,4 @@ node_modules
build
output
dist
_catalog
test-results
test-artifacts
_catalog

View File

@@ -58,13 +58,13 @@ This will compile and run the app in production mode but it won't generate any p
npm run release -- --rebuild
# For macOS
npx electron-builder --config=electron-builder.config.js --mac dmg --arm64 --x64 --publish never
npx electron-builder --mac dmg --arm64 --x64 -publish never
# For Linux (AppImage)
npx electron-builder --config=electron-builder.config.js --linux AppImage:x64 AppImage:arm64 --publish never
npx electron-builder --linux AppImage:x64 AppImage:arm64 -publish never
# For Windows
npx electron-builder --config=electron-builder.config.js --win --publish never
npx electron-builder --win --publish never
```
Feel free to play around with the `electron-builder` command to get the packages you need. `npx electron-builder --help` is a great resource to learn different commands & platforms supported by `electron-builder`.

View File

@@ -1,246 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { test } from "vitest";
import { harness } from "./utils.js";
import { writeFile } from "fs/promises";
import { Page } from "playwright";
import { gt, lt } from "semver";
test("update starts downloading if version is outdated", async (t) => {
await harness(
t,
async ({ page }) => {
await page.waitForSelector("#authForm");
t.expect(
await page.getByRole("button", { name: "Create account" }).isVisible()
).toBe(true);
await page
.getByRole("button", { name: "Skip & go directly to the app" })
.click();
await skipDialog(page);
await page.waitForSelector(".ProseMirror");
await page
.locator(".theme-scope-statusBar")
.getByRole("button", { name: /updating/i })
.waitFor({ state: "attached" });
},
{ version: "3.0.0" }
);
});
test("update is only shown if version is outdated and auto updates are disabled", async (t) => {
await harness(
t,
async (ctx) => {
await ctx.app.close();
await writeFile(
ctx.configPath,
JSON.stringify({
automaticUpdates: false
})
);
await ctx.relaunch();
const { page } = ctx;
await page.waitForSelector("#authForm");
t.expect(
await page.getByRole("button", { name: "Create account" }).isVisible()
).toBe(true);
await page
.getByRole("button", { name: "Skip & go directly to the app" })
.click();
await skipDialog(page);
await page.waitForSelector(".ProseMirror");
await page
.locator(".theme-scope-statusBar")
.getByRole("button", { name: /available/i })
.waitFor({ state: "attached" });
},
{ version: "3.0.0" }
);
});
test("update to stable if it is newer", async (t) => {
await harness(
t,
async (ctx) => {
await ctx.app.close();
await writeFile(
ctx.configPath,
JSON.stringify({
automaticUpdates: false,
releaseTrack: "beta"
})
);
await ctx.relaunch();
const { page } = ctx;
await page.waitForSelector("#authForm");
t.expect(
await page.getByRole("button", { name: "Create account" }).isVisible()
).toBe(true);
await page
.getByRole("button", { name: "Skip & go directly to the app" })
.click();
await skipDialog(page);
await page.waitForSelector(".ProseMirror");
const updateButton = page
.locator(".theme-scope-statusBar")
.getByRole("button", { name: /available/i });
await updateButton.waitFor({ state: "visible" });
const content = await updateButton.textContent();
const version = content?.split(" ")?.[0] || "";
t.expect(gt(version, "3.0.0-beta.0")).toBe(true);
},
{ version: "3.0.0-beta.0" }
);
});
test("update is not available if it latest stable version is older", async (t) => {
await harness(
t,
async (ctx) => {
await ctx.app.close();
await writeFile(
ctx.configPath,
JSON.stringify({
automaticUpdates: false,
releaseTrack: "beta"
})
);
await ctx.relaunch();
const { page } = ctx;
await page.waitForSelector("#authForm");
t.expect(
await page.getByRole("button", { name: "Create account" }).isVisible()
).toBe(true);
await page
.getByRole("button", { name: "Skip & go directly to the app" })
.click();
await skipDialog(page);
await page.waitForSelector(".ProseMirror");
await page
.locator(".theme-scope-statusBar")
.getByRole("button", { name: /checking for updates/i })
.waitFor({ state: "hidden" });
t.expect(
await page
.locator(".theme-scope-statusBar")
.getByRole("button", { name: /available/i })
.isHidden()
).toBe(true);
},
{ version: "99.0.0-beta.0" }
);
});
test("downgrade to stable on switching to stable release track", async (t) => {
await harness(
t,
async (ctx) => {
await ctx.app.close();
await writeFile(
ctx.configPath,
JSON.stringify({
automaticUpdates: false,
releaseTrack: "stable"
})
);
await ctx.relaunch();
const { page } = ctx;
await page.waitForSelector("#authForm");
t.expect(
await page.getByRole("button", { name: "Create account" }).isVisible()
).toBe(true);
await page
.getByRole("button", { name: "Skip & go directly to the app" })
.click();
await skipDialog(page);
await page.waitForSelector(".ProseMirror");
await page
.locator(".theme-scope-statusBar")
.getByRole("button", { name: /checking for updates/i })
.waitFor({ state: "hidden" });
const updateButton = page
.locator(".theme-scope-statusBar")
.getByRole("button", { name: /available/i });
await updateButton.waitFor({ state: "visible" });
const content = await updateButton.textContent();
const version = content?.split(" ")?.[0] || "";
t.expect(lt(version, "99.0.0-beta.0")).toBe(true);
},
{ version: "99.0.0-beta.0" }
);
});
async function skipDialog(page: Page) {
await page
.waitForSelector(".ReactModal__Content", {
timeout: 1000
})
.catch(() => {})
.then(async () => {
const positiveButton = page.locator(
"button[data-role='positive-button']"
);
const negativeButton = page.locator(
"button[data-role='negative-button']"
);
if (await positiveButton.isVisible()) await positiveButton.click();
else if (await negativeButton.isVisible()) await negativeButton.click();
});
}

View File

@@ -1,172 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { execSync } from "child_process";
import { mkdir } from "fs/promises";
import { fileURLToPath } from "node:url";
import path from "path";
import { _electron as electron } from "playwright";
import slugify from "slugify";
import { TaskContext } from "vitest";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const IS_DEBUG = process.env.NN_DEBUG === "true" || process.env.CI === "true";
interface AppContext {
app: import("playwright").ElectronApplication;
page: import("playwright").Page;
configPath: string;
relaunch: () => Promise<void>;
}
interface TestOptions {
version: string;
}
export async function harness(
t: TaskContext,
cb: (ctx: AppContext) => Promise<void>,
options?: TestOptions
) {
const ctx = await buildAndLaunchApp(options);
t.onTestFinished(async (result) => {
if (result.state === "fail") {
await mkdir("test-results", { recursive: true });
await ctx.page.screenshot({
path: path.join(
"test-results",
`${slugify(t.task.name)}-${process.platform}-${
process.arch
}-error.png`
)
});
}
await ctx.app.close();
});
await cb(ctx);
}
async function buildAndLaunchApp(options?: TestOptions): Promise<AppContext> {
const productName = makeid(10);
const executablePath = await buildApp({ ...options, productName });
const { app, page, configPath } = await launchApp(executablePath);
const ctx: AppContext = {
app,
page,
configPath,
relaunch: async () => {
const { app, page, configPath } = await launchApp(executablePath);
ctx.app = app;
ctx.page = page;
ctx.configPath = configPath;
}
};
return ctx;
}
async function launchApp(executablePath: string) {
const app = await electron.launch({
executablePath,
args: IS_DEBUG ? [] : ["--hidden"],
env:
process.platform === "linux"
? {
...(process.env as Record<string, string>),
APPIMAGE: "true"
}
: (process.env as Record<string, string>)
});
const page = await app.firstWindow();
const userDataDirectory = await app.evaluate((a) => {
return a.app.getPath("userData");
});
const configPath = path.join(userDataDirectory, "config.json");
return {
app,
page,
configPath
};
}
async function buildApp({
version,
productName
}: {
version?: string;
productName: string;
}) {
const buildRoot = path.join("test-artifacts", `${productName}-build`);
const output = path.join("test-artifacts", `${productName}-output`);
execSync(`npm run release -- --root ${buildRoot} --skip-tsc-build`, {
stdio: IS_DEBUG ? "inherit" : "ignore"
});
const args = [
`--config electron-builder.config.js`,
`--c.extraMetadata.productName=${productName}`,
"--publish=never"
];
if (version) args.push(`--c.extraMetadata.version=${version}`);
execSync(`npx electron-builder --dir --${process.arch} ${args.join(" ")}`, {
stdio: IS_DEBUG ? "inherit" : "ignore",
env: {
...process.env,
NOTESNOOK_STAGING: "true",
NN_BUILD_ROOT: buildRoot,
NN_PRODUCT_NAME: productName,
NN_APP_ID: `com.notesnook.test.${productName}`,
NN_OUTPUT_DIR: output
}
});
return path.join(
__dirname,
"..",
output,
process.platform === "linux"
? process.arch === "arm64"
? "linux-arm64-unpacked"
: "linux-unpacked"
: process.platform === "darwin"
? process.arch === "arm64"
? `mac-arm64/${productName}.app/Contents/MacOS/`
: `mac/${productName}.app/Contents/MacOS/`
: "win-unpacked",
process.platform === "win32" ? `${productName}.exe` : productName
);
}
function makeid(length: number) {
let result = "";
const characters =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const charactersLength = characters.length;
let counter = 0;
while (counter < length) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
counter += 1;
}
return result.toLowerCase();
}

Binary file not shown.

View File

@@ -1,219 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const path = require("path");
const pkg = require("./package.json");
const buildRoot = process.env.NN_BUILD_ROOT || ".";
const buildFiles = [
`${buildRoot}/build/`,
`!${buildRoot}/build/screenshots\${/*}`,
`!${buildRoot}/build/banner.jpg`,
`!${buildRoot}/build/*.ico`,
`!${buildRoot}/build/*.png`
];
const productName = process.env.NN_PRODUCT_NAME || "Notesnook";
const appId = process.env.NN_APP_ID || "org.streetwriters.notesnook";
const outputDir = process.env.NN_OUTPUT_DIR || "output";
const linuxExecutableName = process.env.NN_PRODUCT_NAME
? process.env.NN_PRODUCT_NAME.toLowerCase().replace(/\s+/g, "-")
: "notesnook";
const year = new Date().getFullYear();
const isBeta = pkg.version.includes("-beta");
/**
* @type {import("app-builder-lib").Configuration}
*/
module.exports = {
appId: appId,
productName: productName,
copyright: `Copyright © ${year} Streetwriters (Private) Limited`,
artifactName: "notesnook_${os}_${arch}.${ext}",
generateUpdatesFilesForAllChannels: true,
asar: false,
files: [
"!*.chunk.js.map",
"!*.chunk.js.LICENSE.txt",
...buildFiles,
"!node_modules${/*}",
"node_modules/better-sqlite3-multiple-ciphers/build/Release/better_sqlite3.node",
"node_modules/better-sqlite3-multiple-ciphers/lib",
"node_modules/better-sqlite3-multiple-ciphers/package.json",
"node_modules/file-uri-to-path",
"node_modules/bindings",
"node_modules/node-gyp-build",
"node_modules/sqlite-better-trigram",
"node_modules/sqlite3-fts5-html",
"node_modules/sodium-native/prebuilds/${platform}-${arch}",
{
from: "node_modules/sqlite-better-trigram-linux-${arch}",
to: "node_modules/sqlite-better-trigram-linux-${arch}"
},
{
from: "node_modules/sqlite-better-trigram-darwin-${arch}",
to: "node_modules/sqlite-better-trigram-darwin-${arch}"
},
{
from: "node_modules/sqlite-better-trigram-windows-${arch}",
to: "node_modules/sqlite-better-trigram-windows-${arch}"
},
{
from: "node_modules/sqlite3-fts5-html-linux-${arch}",
to: "node_modules/sqlite3-fts5-html-linux-${arch}"
},
{
from: "node_modules/sqlite3-fts5-html-darwin-${arch}",
to: "node_modules/sqlite3-fts5-html-darwin-${arch}"
},
{
from: "node_modules/sqlite3-fts5-html-windows-${arch}",
to: "node_modules/sqlite3-fts5-html-windows-${arch}"
},
"node_modules/sodium-native/index.js",
"node_modules/sodium-native/package.json"
],
afterPack: "./scripts/removeLocales.js",
mac: {
bundleVersion: "240",
minimumSystemVersion: "10.12.0",
target: [
{
target: "dmg",
arch: ["arm64", "x64"]
},
{
target: "zip",
arch: ["arm64", "x64"]
}
],
category: "public.app-category.productivity",
darkModeSupport: true,
type: "distribution",
hardenedRuntime: true,
entitlements: "assets/entitlements.mac.plist",
entitlementsInherit: "assets/entitlements.mac.plist",
gatekeeperAssess: false,
icon: "assets/icons/app.icns",
notarize: true
},
dmg: {
contents: [
{
x: 130,
y: 220
},
{
x: 410,
y: 220,
type: "link",
path: "/Applications"
}
],
icon: "assets/icons/app.icns",
title: "Install Notesnook"
},
mas: {
entitlements: "assets/entitlements.mas.plist",
entitlementsInherit: "assets/entitlements.mas.inherit.plist",
entitlementsLoginHelper: "assets/entitlements.mas.loginhelper.plist",
hardenedRuntime: true
},
win: {
target: [
{
target: "nsis",
arch: ["x64", "arm64"]
},
{
target: "portable",
arch: ["x64", "arm64"]
}
],
signtoolOptions: {
signingHashAlgorithms: ["sha256"],
sign: "./scripts/sign.js"
},
icon: "assets/icons/app.ico"
},
portable: {
artifactName: "notesnook_${os}_${arch}_portable.${ext}"
},
nsis: {
oneClick: true,
createDesktopShortcut: "always",
deleteAppDataOnUninstall: true
},
linux: {
target: [
{
target: "AppImage",
arch: ["x64", "arm64"]
},
{
target: "snap",
arch: ["x64", "arm64"]
}
],
category: "Office",
icon: "assets/icons/app.icns",
description: "Your private note taking space",
executableName: linuxExecutableName,
desktop: {
desktopActions: {
"new-note": {
Name: "New note",
Exec: `${linuxExecutableName} new note`
},
"new-notebook": {
Name: "New notebook",
Exec: `${linuxExecutableName} new notebook`
},
"new-reminder": {
Name: "New reminder",
Exec: `${linuxExecutableName} new reminder`
}
}
}
},
snap: {
autoStart: false,
confinement: "strict",
allowNativeWayland: true,
base: "core22"
},
extraResources: ["app-update.yml", "./assets/**"],
extraMetadata: {
main: path.join(buildRoot, "build", "electron.js")
},
directories: {
buildResources: "assets",
output: outputDir
},
publish: [
{
provider: "github",
repo: "notesnook",
owner: "streetwriters",
channel: isBeta ? "beta" : "latest"
}
]
};

File diff suppressed because it is too large Load Diff

View File

@@ -2,80 +2,228 @@
"name": "@notesnook/desktop",
"productName": "Notesnook",
"description": "Your private note taking space",
"version": "3.2.3",
"version": "3.0.13",
"appAppleId": "1544027013",
"private": true,
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts",
"sideEffects": false,
"exports": {
".": {
"require": {
"types": "./dist/types/index.d.ts",
"default": "./dist/cjs/index.js"
},
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/esm/index.js"
}
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"homepage": "https://notesnook.com/",
"repository": "https://github.com/streetwriters/notesnook",
"license": "GPL-3.0-or-later",
"dependencies": {
"@lingui/core": "5.1.2",
"@notesnook/intl": "file:../../packages/intl",
"@notesnook/ui": "file:../../packages/ui",
"@trpc/client": "10.45.2",
"@trpc/server": "10.45.2",
"better-sqlite3-multiple-ciphers": "^11.10.0",
"electron-trpc": "0.7.1",
"electron-updater": "^6.6.2",
"icojs": "^0.19.5",
"sqlite-better-trigram": "0.0.3",
"sqlite3-fts5-html": "^0.0.4",
"@notesnook/crypto": "file:../../packages/crypto",
"@trpc/client": "10.38.3",
"@trpc/server": "10.38.3",
"better-sqlite3-multiple-ciphers": "^9.5.0",
"electron-trpc": "0.5.2",
"electron-updater": "^6.2.1",
"icojs": "^0.17.1",
"sodium-native": "^4.1.1",
"typed-emitter": "^2.1.0",
"yargs": "^17.7.2",
"zod": "3.24.3"
"yargs": "^17.6.2",
"zod": "^3.21.4"
},
"devDependencies": {
"@streetwriters/kysely": "^0.27.4",
"@types/node": "22.15.3",
"@types/yargs": "^17.0.33",
"chokidar": "^4.0.3",
"electron": "^34.0.0",
"electron-builder": "^26.0.12",
"esbuild": "0.21.5",
"node-abi": "^4.5.0",
"node-gyp-build": "^4.8.4",
"playwright": "1.48.2",
"@types/node": "18.16.1",
"@types/yargs": "^17.0.24",
"chokidar": "^3.5.3",
"electron": "^29.3.1",
"electron-builder": "^24.13.3",
"esbuild": "^0.23.0",
"kysely": "^0.27.3",
"node-gyp-build": "^4.8.0",
"prebuildify": "^6.0.1",
"slugify": "1.6.6",
"tree-kill": "^1.2.2",
"undici": "^7.8.0",
"vitest": "2.1.8"
"undici": "^6.14.1"
},
"optionalDependencies": {
"dmg-license": "^1.0.11"
},
"overrides": {
"@electron/node-gyp": "^10.2.0-electron.2"
},
"scripts": {
"start": "node scripts/dev.mjs",
"staging": "node scripts/build.mjs --run",
"release": "node scripts/build.mjs",
"build": "node ../../scripts/build.mjs",
"bundle": "esbuild electron=./src/main.ts ./src/preload.ts --external:electron --external:fsevents --external:better-sqlite3-multiple-ciphers --external:sodium-native --bundle --outdir=./build --platform=node --tsconfig=tsconfig.json --define:MAC_APP_STORE=false --define:RELEASE=true",
"build": "tsc",
"bundle": "esbuild electron=./src/main.ts ./src/preload.ts --external:electron --external:fsevents --external:better-sqlite3-multiple-ciphers --external:sodium-native --minify --bundle --outdir=./build --platform=node --tsconfig=tsconfig.json --define:MAC_APP_STORE=false --define:RELEASE=true",
"bundle:mas": "esbuild electron=./src/main.ts ./src/preload.ts --minify --external:electron --external:fsevents --bundle --outdir=./build --platform=node --tsconfig=tsconfig.json --define:MAC_APP_STORE=true --define:RELEASE=true",
"postinstall": "patch-package",
"test": "vitest run"
"postinstall": "patch-package"
},
"author": {
"name": "Streetwriters (Private) Limited",
"email": "support@streetwriters.co",
"url": "https://streetwriters.co"
},
"build": {
"appId": "org.streetwriters.notesnook",
"productName": "Notesnook",
"copyright": "Copyright © 2023 Streetwriters (Private) Limited",
"artifactName": "notesnook_${os}_${arch}.${ext}",
"generateUpdatesFilesForAllChannels": true,
"asar": false,
"files": [
"!*.chunk.js.map",
"!*.chunk.js.LICENSE.txt",
"build/",
"!build/screenshots${/*}",
"!build/banner.jpg",
"!build/*.ico",
"!build/*.png",
"!node_modules${/*}",
"node_modules/better-sqlite3-multiple-ciphers/build/Release/better_sqlite3.node",
"node_modules/better-sqlite3-multiple-ciphers/lib",
"node_modules/better-sqlite3-multiple-ciphers/package.json",
"node_modules/file-uri-to-path",
"node_modules/bindings",
"node_modules/node-gyp-build",
"node_modules/sodium-native/prebuilds/${platform}-${arch}",
"node_modules/sodium-native/index.js",
"node_modules/sodium-native/package.json"
],
"afterPack": "./scripts/removeLocales.js",
"mac": {
"bundleVersion": "240",
"minimumSystemVersion": "10.12.0",
"target": [
{
"target": "dmg",
"arch": [
"arm64",
"x64"
]
},
{
"target": "zip",
"arch": [
"arm64",
"x64"
]
}
],
"category": "public.app-category.productivity",
"darkModeSupport": true,
"type": "distribution",
"hardenedRuntime": true,
"entitlements": "assets/entitlements.mac.plist",
"entitlementsInherit": "assets/entitlements.mac.plist",
"gatekeeperAssess": false,
"icon": "assets/icons/app.icns",
"notarize": true
},
"dmg": {
"contents": [
{
"x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
],
"icon": "assets/icons/app.icns",
"title": "Install Notesnook"
},
"mas": {
"entitlements": "assets/entitlements.mas.plist",
"entitlementsInherit": "assets/entitlements.mas.inherit.plist",
"entitlementsLoginHelper": "assets/entitlements.mas.loginhelper.plist",
"hardenedRuntime": true
},
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64",
"arm64"
]
},
{
"target": "portable",
"arch": [
"x64",
"arm64"
]
}
],
"signingHashAlgorithms": [
"sha256"
],
"sign": "./scripts/sign.js",
"icon": "assets/icons/app.ico"
},
"portable": {
"artifactName": "notesnook_${os}_${arch}_portable.${ext}"
},
"nsis": {
"oneClick": true,
"createDesktopShortcut": "always"
},
"linux": {
"target": [
{
"target": "AppImage",
"arch": [
"x64",
"arm64"
]
},
{
"target": "snap",
"arch": [
"x64",
"arm64"
]
}
],
"category": "Office",
"icon": "assets/icons/app.icns",
"description": "Your private note taking space",
"executableName": "notesnook",
"desktop": {
"actions": [
{
"id": "new-note",
"name": "New note",
"args": "new note"
},
{
"id": "new-notebook",
"name": "New notebook",
"args": "new notebook"
},
{
"id": "new-reminder",
"name": "New reminder",
"args": "new reminder"
}
]
}
},
"snap": {
"autoStart": false,
"confinement": "strict",
"allowNativeWayland": true
},
"extraResources": [
"app-update.yml",
"./assets/**"
],
"extraMetadata": {
"main": "./build/electron.js"
},
"directories": {
"buildResources": "assets",
"output": "./output/"
},
"publish": [
{
"provider": "github",
"repo": "notesnook",
"owner": "streetwriters"
}
]
}
}

View File

@@ -0,0 +1,36 @@
diff --git a/node_modules/app-builder-lib/out/targets/LinuxTargetHelper.js b/node_modules/app-builder-lib/out/targets/LinuxTargetHelper.js
index fcb7f54..cb1c7f7 100644
--- a/node_modules/app-builder-lib/out/targets/LinuxTargetHelper.js
+++ b/node_modules/app-builder-lib/out/targets/LinuxTargetHelper.js
@@ -116,6 +116,7 @@ class LinuxTargetHelper {
StartupWMClass: appInfo.productName,
...extra,
...targetSpecificOptions.desktop,
+ actions: undefined
};
const description = this.getDescription(targetSpecificOptions);
if (!(0, builder_util_1.isEmptyOrSpaces)(description)) {
@@ -159,6 +160,23 @@ class LinuxTargetHelper {
data += `\n${name}=${desktopMeta[name]}`;
}
data += "\n";
+
+ if (targetSpecificOptions.desktop.actions) {
+ let actionsData = "";
+ const validActions = [];
+ for (const action of targetSpecificOptions.desktop.actions) {
+ if (!action.id || !action.name || !action.args) continue;
+ actionsData += "\n";
+ actionsData += `[Desktop Action ${action.id}]
+Name=${action.name}
+Exec=${desktopMeta.Exec} ${action.args}`;
+ actionsData += "\n";
+
+ validActions.push(action.id);
+ }
+
+ data += `Actions=${validActions.join(";")};\n${actionsData}`
+ }
return Promise.resolve(data);
}
}

View File

@@ -1,13 +0,0 @@
diff --git a/node_modules/app-builder-lib/out/targets/snap.js b/node_modules/app-builder-lib/out/targets/snap.js
index 0b5405e..753f6c6 100644
--- a/node_modules/app-builder-lib/out/targets/snap.js
+++ b/node_modules/app-builder-lib/out/targets/snap.js
@@ -115,7 +115,7 @@ class SnapTarget extends core_1.Target {
else {
const archTriplet = archNameToTriplet(arch);
appDescriptor.environment = {
- DISABLE_WAYLAND: options.allowNativeWayland ? "" : "1",
+ DISABLE_WAYLAND: options.allowNativeWayland ? "0" : "1",
PATH: "$SNAP/usr/sbin:$SNAP/usr/bin:$SNAP/sbin:$SNAP/bin:$PATH",
SNAP_DESKTOP_RUNTIME: "$SNAP/gnome-platform",
LD_LIBRARY_PATH: [

View File

@@ -1,11 +0,0 @@
diff --git a/node_modules/better-sqlite3-multiple-ciphers/deps/defines.gypi b/node_modules/better-sqlite3-multiple-ciphers/deps/defines.gypi
index 1a14ecd..ff938f1 100644
--- a/node_modules/better-sqlite3-multiple-ciphers/deps/defines.gypi
+++ b/node_modules/better-sqlite3-multiple-ciphers/deps/defines.gypi
@@ -38,5 +38,6 @@
'SQLITE_TRACE_SIZE_LIMIT=32',
'SQLITE_USER_AUTHENTICATION=0',
'SQLITE_USE_URI=0',
+ 'SQLITE_ENABLE_REGEXP',
],
}

View File

@@ -0,0 +1,23 @@
diff --git a/node_modules/electron-trpc/dist/main.mjs b/node_modules/electron-trpc/dist/main.mjs
index 379cf3b..2644e5d 100644
--- a/node_modules/electron-trpc/dist/main.mjs
+++ b/node_modules/electron-trpc/dist/main.mjs
@@ -221,9 +221,16 @@ class G {
i(this, c).includes(r) || (i(this, c).push(r), I(this, T, W).call(this, r));
}
detachWindow(r) {
+
y(this, c, i(this, c).filter((n) => n !== r));
- for (const [n, t] of i(this, u).entries())
- n.startsWith(`${r.webContents.id}-`) && (t.unsubscribe(), i(this, u).delete(n));
+ for (const [n, t] of i(this, u).entries()) {
+ try {
+ n.startsWith(`${r.webContents.id}-`) && (t.unsubscribe(), i(this, u).delete(n));
+ } catch(e) {
+ console.error(e);
+ // ignore
+ }
+ }
}
}
c = new WeakMap(), u = new WeakMap(), T = new WeakSet(), W = function(r) {

View File

@@ -0,0 +1,46 @@
diff --git a/node_modules/node-gyp-build/bin.js b/node_modules/node-gyp-build/bin.js
index 3fbcdf0..7ca3ab5 100644
--- a/node_modules/node-gyp-build/bin.js
+++ b/node_modules/node-gyp-build/bin.js
@@ -16,7 +16,8 @@ if (!buildFromSource()) {
}
function build () {
- var args = [os.platform() === 'win32' ? 'node-gyp.cmd' : 'node-gyp', 'rebuild']
+ var win32 = os.platform() === 'win32'
+ var args = [win32 ? 'node-gyp.cmd' : 'node-gyp', 'rebuild']
try {
var pkg = require('node-gyp/package.json')
@@ -27,7 +28,7 @@ function build () {
]
} catch (_) {}
- proc.spawn(args[0], args.slice(1), { stdio: 'inherit' }).on('exit', function (code) {
+ proc.spawn(args[0], args.slice(1), { stdio: 'inherit', shell: win32, windowsHide: true }).on('exit', function (code) {
if (code || !process.argv[3]) process.exit(code)
exec(process.argv[3]).on('exit', function (code) {
process.exit(code)
@@ -45,15 +46,18 @@ function preinstall () {
function exec (cmd) {
if (process.platform !== 'win32') {
- var shell = os.platform() === 'android' ? 'sh' : '/bin/sh'
- return proc.spawn(shell, ['-c', '--', cmd], {
+ var shell = os.platform() === 'android' ? 'sh' : true
+ return proc.spawn(cmd, [], {
+ shell,
stdio: 'inherit'
})
}
- return proc.spawn(process.env.comspec || 'cmd.exe', ['/s', '/c', '"' + cmd + '"'], {
+ return proc.spawn(cmd, [], {
windowsVerbatimArguments: true,
- stdio: 'inherit'
+ stdio: 'inherit',
+ shell: true,
+ windowsHide: true
})
}

View File

@@ -18,58 +18,59 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import path from "path";
import fs from "fs/promises";
import { existsSync } from "fs";
import fs, { readFile, writeFile } from "fs/promises";
import { existsSync, readFileSync } from "fs";
import yargs from "yargs-parser";
import os from "os";
import * as childProcess from "child_process";
import { fileURLToPath } from "url";
import { patchBetterSQLite3 } from "./patch-better-sqlite3.mjs";
const args = yargs(process.argv);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const root = args.root || path.join(__dirname, "..");
const skipTscBuild = args.skipTscBuild || false;
const packageJson = JSON.parse(
readFileSync(path.join(__dirname, "..", "package.json"), "utf-8")
);
const webAppPath = path.resolve(path.join(__dirname, "..", "..", "web"));
await fs.rm(path.join(root, "build"), { force: true, recursive: true });
await fs.rm("./build/", { force: true, recursive: true });
if (args.rebuild || !existsSync(path.join(webAppPath, "build"))) {
await exec(
"npx nx build:desktop @notesnook/web",
"yarn nx build:desktop @notesnook/web",
path.join(__dirname, "..", "..", "..")
);
}
await patchBetterSQLite3();
// temporary until there's support for prebuilt binaries for linux ARM
if (os.platform() === "linux") await patchBetterSQLite3();
await fs.cp(path.join(webAppPath, "build"), path.join(root, "build"), {
// if (os.platform() === "win32")
// await exec(
// `npx prebuildify --arch=arm64 --strip -t electron@${packageJson.devDependencies.electron}`,
// path.join(__dirname, "..", "node_modules", "sodium-native")
// );
await fs.cp(path.join(webAppPath, "build"), "build", {
recursive: true,
force: true
});
if (args.variant === "mas") {
await exec(`yarn run bundle:mas --outdir=${path.join(root, "build")}`);
await exec(`yarn run bundle:mas`);
} else {
await exec(`yarn run bundle --outdir=${path.join(root, "build")}`);
await exec(`yarn run bundle`);
}
if (!skipTscBuild) {
await exec(`yarn run build`);
}
await exec(`yarn tsc`);
if (args.run) {
await exec(
`yarn electron-builder --dir --${process.arch} --config=electron-builder.config.js`
);
await exec(`yarn electron-builder --dir --x64`);
if (process.platform === "win32") {
await exec(`.\\output\\win-unpacked\\Notesnook.exe`);
} else if (process.platform === "darwin") {
if (process.arch === "arm64")
await exec(`./output/mac-arm64/Notesnook.app/Contents/MacOS/Notesnook`);
else await exec(`./output/mac/Notesnook.app/Contents/MacOS/Notesnook`);
await exec(`./output/mac/Notesnook.app/Contents/MacOS/Notesnook`);
} else {
await exec(`./output/linux-unpacked/Notesnook`);
}
@@ -82,3 +83,21 @@ async function exec(cmd, cwd) {
cwd: cwd || process.cwd()
});
}
async function patchBetterSQLite3() {
const jsonPath = path.join(
__dirname,
"..",
"node_modules",
"better-sqlite3-multiple-ciphers",
"package.json"
);
const json = JSON.parse(await readFile(jsonPath, "utf-8"));
json.version = "9.5.1";
json.homepage = "https://github.com/thecodrr/better-sqlite3-multiple-ciphers";
json.repository.url =
"git://github.com/thecodrr/better-sqlite3-multiple-ciphers.git";
await writeFile(jsonPath, JSON.stringify(json));
}

View File

@@ -36,8 +36,6 @@ const ENV = {
FORCE_COLOR: "false",
COLOR: "0"
};
process.chdir(path.join(__dirname, ".."));
await onChange(true);
console.log("Watching...");
@@ -55,13 +53,11 @@ async function onChange(first) {
if (first) {
await fs.rm("./build/", { force: true, recursive: true });
await exec("npm rebuild electron --verbose --foreground-scripts");
await exec("yarn electron-builder install-app-deps");
}
await exec(`yarn run bundle`);
await exec(`yarn run build`);
execAsync(`yarn`, [`tsc`]);
if (await isBundleSame()) {
console.log("Bundle is same. Doing nothing.");
@@ -70,8 +66,8 @@ async function onChange(first) {
if (first) {
await spawnAndWaitUntil(
["npm", "run", "start:desktop"],
path.join(__dirname, "..", "..", "web"),
["yarn", "nx", "start:desktop", "@notesnook/web"],
path.join(__dirname, "..", "..", ".."),
(data) => data.includes("Network: use --host to expose")
);
}

View File

@@ -1,58 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { readFile, rm, writeFile } from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export async function patchBetterSQLite3() {
console.log("Patching better-sqlite3");
const jsonPath = path.join(
__dirname,
"..",
"node_modules",
"better-sqlite3-multiple-ciphers",
"package.json"
);
const json = JSON.parse(await readFile(jsonPath, "utf-8"));
delete json.homepage;
delete json.repository;
await writeFile(jsonPath, JSON.stringify(json));
await rm(
path.join(
__dirname,
"..",
"node_modules",
"better-sqlite3-multiple-ciphers",
"build"
),
{ force: true, recursive: true }
);
}
if (process.argv[1] === __filename) {
patchBetterSQLite3();
}

View File

@@ -19,16 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { initTRPC } from "@trpc/server";
import { z } from "zod";
import {
app,
dialog,
Menu,
MenuItem,
nativeImage,
nativeTheme,
Notification,
shell
} from "electron";
import { app, dialog, nativeTheme, Notification, shell } from "electron";
import { AutoLaunch } from "../utils/autolaunch";
import { config, DesktopIntegration } from "../utils/config";
import { bringToFront } from "../utils/bring-to-front";
@@ -38,11 +29,10 @@ import { dirname } from "path";
import { resolvePath } from "../utils/resolve-path";
import { observable } from "@trpc/server/observable";
import { AssetManager } from "../utils/asset-manager";
import { isFlatpak, isSnap } from "../utils";
import { isFlatpak } from "../utils";
import { setupDesktopIntegration } from "../utils/desktop-integration";
import { rm } from "fs/promises";
import { disableCustomDns, enableCustomDns } from "../utils/custom-dns";
import type { MenuItem as NNMenuItem } from "@notesnook/ui";
const t = initTRPC.create();
@@ -59,7 +49,6 @@ const NotificationOptions = z.object({
export const osIntegrationRouter = t.router({
isFlatpak: t.procedure.query(() => isFlatpak()),
isSnap: t.procedure.query(() => isSnap()),
zoomFactor: t.procedure.query(() => config.zoomFactor),
setZoomFactor: t.procedure.input(z.number()).mutation(({ input: factor }) => {
@@ -235,63 +224,5 @@ export const osIntegrationRouter = t.router({
nativeTheme.off("updated", updated);
};
})
),
showMenu: t.procedure
.input(
z.object({
menuItems: z.array(z.any())
})
)
.subscription(({ input: { menuItems } }) =>
observable<string[]>((emit) => {
const items = menuItems as NNMenuItem[];
const menu = new Menu();
for (const item of items) {
const menuItem = toMenuItem(item, (id) => emit.next(id));
if (menuItem) menu.append(menuItem);
}
if (menu.items.length > 0) menu.popup();
return () => {
menu.removeAllListeners();
menu.closePopup();
};
})
)
)
});
function toMenuItem(
item: NNMenuItem,
onClick: (id: string[]) => void,
parentKey?: string
): MenuItem | undefined {
switch (item.type) {
case "lazy-loader":
return undefined;
case "separator":
return new MenuItem({ type: "separator" });
case "button": {
const submenu = item.menu ? new Menu() : undefined;
if (submenu && item.menu) {
for (const subitem of item.menu.items) {
const subMenuItem = toMenuItem(subitem, onClick, item.key);
if (subMenuItem) submenu.append(subMenuItem);
}
}
return new MenuItem({
label: item.title,
enabled: !item.isDisabled,
visible: !item.isHidden,
toolTip: item.tooltip,
sublabel: item.tooltip,
checked: item.isChecked,
type: submenu ? "submenu" : item.isChecked ? "checkbox" : "normal",
id: item.key,
submenu,
click: () => onClick(parentKey ? [parentKey, item.key] : [item.key]),
accelerator: item.modifier?.replace("Mod", "CommandOrControl")
});
}
}
}

View File

@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import type { Database, Statement } from "better-sqlite3-multiple-ciphers";
import type { QueryResult } from "@streetwriters/kysely";
import type { QueryResult } from "kysely";
type SQLiteCompatibleType =
| number
@@ -33,8 +33,6 @@ export class SQLite {
initialized = false;
preparedStatements: Map<string, Statement<unknown[]>> = new Map();
retryCounter: Record<string, number> = {};
extensionsLoaded = false;
constructor() {
console.log("new sqlite worker");
}
@@ -115,20 +113,6 @@ export class SQLite {
} catch (e) {
if (e instanceof Error) e.message += ` (query: ${sql})`;
throw e;
} finally {
// Since SQLite 3.48.0 (SQLite3MC v2.0.2) it's not possible to load fts5
// extensions before database has been decrypting. This is because
// executing a `SELECT` now accesses the underlying databases resulting in
// an error. Since FTS5 extensions depend on `SELECT fts5` to load the
// fts5 API, we must wait decrypt the database before we can load
// the extensions.
if (!this.extensionsLoaded && (await this.isDatabaseReady())) {
const betterTrigram = require("sqlite-better-trigram");
const fts5Html = require("sqlite3-fts5-html");
betterTrigram.load(this.sqlite);
fts5Html.load(this.sqlite);
this.extensionsLoaded = true;
}
}
}
@@ -156,22 +140,4 @@ export class SQLite {
retryDelay: 500
});
}
/**
* This just executes `SELECT 1` on the database to make sure its ready.
* On an encrypted database, this will fail until `PRAGMA key` has been
* called.
*/
private async isDatabaseReady() {
// return this.exec(`SELECT 1;`)
// .then(() => true)
// .catch(() => false);
if (!this.sqlite) return false;
try {
this.sqlite.prepare(`SELECT 1;`).run();
return true;
} catch {
return false;
}
}
}

View File

@@ -23,45 +23,25 @@ import { CancellationToken, autoUpdater } from "electron-updater";
import type { AppUpdaterEvents } from "electron-updater/out/AppUpdater";
import { z } from "zod";
import { config } from "../utils/config";
import { app } from "electron";
type UpdateInfo = { version: string };
type Progress = { percent: number };
const t = initTRPC.create();
let cancellationToken: CancellationToken | undefined = undefined;
let downloadTimeout: NodeJS.Timeout | undefined = undefined;
export const updaterRouter = t.router({
autoUpdates: t.procedure.query(() => config.automaticUpdates),
releaseTrack: t.procedure.query(() => config.releaseTrack),
install: t.procedure.query(() => autoUpdater.quitAndInstall()),
download: t.procedure.query(async () => {
if (cancellationToken) return;
clearTimeout(downloadTimeout);
await new Promise<string[]>((resolve, reject) => {
downloadTimeout = setTimeout(async () => {
cancellationToken = new CancellationToken();
autoUpdater.isUpdaterActive();
await autoUpdater
.downloadUpdate(cancellationToken)
.then(resolve)
.catch(reject)
.finally(() => (cancellationToken = undefined));
}, 1000);
});
cancellationToken = new CancellationToken();
await autoUpdater
.downloadUpdate(cancellationToken)
.finally(() => (cancellationToken = undefined));
}),
check: t.procedure.query(async () => {
if (cancellationToken) return;
clearTimeout(downloadTimeout);
await new Promise<void>((resolve) => {
downloadTimeout = setTimeout(async () => {
await autoUpdater
.checkForUpdates()
.catch(console.error)
.finally(resolve);
}, 1000);
});
await autoUpdater.checkForUpdates().catch(console.error);
}),
toggleAutoUpdates: t.procedure
@@ -69,13 +49,7 @@ export const updaterRouter = t.router({
.mutation(({ input: { enabled } }) => {
config.automaticUpdates = enabled;
}),
changeReleaseTrack: t.procedure
.input(z.object({ track: z.string() }))
.mutation(({ input: { track } }) => {
config.releaseTrack = track;
app.relaunch();
app.exit();
}),
onChecking: createSubscription("checking-for-update"),
onDownloaded: createSubscription<"update-downloaded", UpdateInfo>(
"update-downloaded"

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export { PATHS } from "./constants";
export * from "./constants";
export type { AppRouter } from "./api";
export { type UpdateInfo } from "builder-util-runtime";
export { type DesktopIntegration } from "./utils/config";

View File

@@ -36,20 +36,6 @@ import { bringToFront } from "./utils/bring-to-front";
import { bridge } from "./api/bridge";
import { setupDesktopIntegration } from "./utils/desktop-integration";
import { disableCustomDns, enableCustomDns } from "./utils/custom-dns";
import { Messages, setI18nGlobal } from "@notesnook/intl";
import { i18n } from "@lingui/core";
const locale =
process.env.NODE_ENV === "development"
? import("@notesnook/intl/locales/$pseudo-LOCALE.json")
: import("@notesnook/intl/locales/$en.json");
locale.then(({ default: locale }) => {
i18n.load({
en: locale.messages as unknown as Messages
});
i18n.activate("en");
});
setI18nGlobal(i18n);
// only run a single instance
if (!MAC_APP_STORE && !app.requestSingleInstanceLock()) {
@@ -85,8 +71,6 @@ async function createWindow() {
const mainWindowState = new WindowState({});
const mainWindow = new BrowserWindow({
show: !cliOptions.hidden,
paintWhenInitiallyHidden: cliOptions.hidden,
skipTaskbar: cliOptions.hidden,
x: mainWindowState.x,
y: mainWindowState.y,
width: mainWindowState.width,
@@ -103,10 +87,7 @@ async function createWindow() {
...(config.desktopSettings.nativeTitlebar
? {}
: {
titleBarStyle:
process.platform === "win32" || process.platform === "darwin"
? "hidden"
: "default",
titleBarStyle: "hidden",
frame: process.platform === "win32" || process.platform === "darwin",
titleBarOverlay: {
height: 37,
@@ -147,11 +128,6 @@ async function createWindow() {
await AssetManager.loadIcons();
setupDesktopIntegration(config.desktopSettings);
mainWindow.webContents.session.setPermissionRequestHandler(
(webContents, permission, callback) => {
callback(permission === "geolocation" ? false : true);
}
);
mainWindow.webContents.session.setSpellCheckerDictionaryDownloadURL(
"http://dictionaries.notesnook.com/"
);

View File

@@ -19,25 +19,26 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/* eslint-disable no-var */
import { ELECTRON_TRPC_CHANNEL } from "electron-trpc/main";
// import type { NNCrypto } from "@notesnook/crypto";
import { type RendererGlobalElectronTRPC } from "electron-trpc/src/types";
import type { NNCrypto } from "@notesnook/crypto";
import { ipcRenderer } from "electron";
import { platform } from "os";
declare global {
var os: () => "mas" | ReturnType<typeof platform>;
var electronTRPC: any;
// var NativeNNCrypto: (new () => NNCrypto) | undefined;
var electronTRPC: RendererGlobalElectronTRPC;
var NativeNNCrypto: (new () => NNCrypto) | undefined;
}
process.once("loaded", async () => {
const electronTRPC = {
sendMessage: (operation: any) =>
const electronTRPC: RendererGlobalElectronTRPC = {
sendMessage: (operation) =>
ipcRenderer.send(ELECTRON_TRPC_CHANNEL, operation),
onMessage: (callback: any) =>
onMessage: (callback) =>
ipcRenderer.on(ELECTRON_TRPC_CHANNEL, (_event, args) => callback(args))
};
globalThis.electronTRPC = electronTRPC;
});
// globalThis.NativeNNCrypto = require("@notesnook/crypto").NNCrypto;
globalThis.NativeNNCrypto = require("@notesnook/crypto").NNCrypto;
globalThis.os = () => (MAC_APP_STORE ? "mas" : platform());

View File

@@ -20,7 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { NativeImage, nativeImage } from "electron";
import path from "path";
import { isDevelopment } from "./index";
import { ParsedImage, parseICO } from "icojs";
import { parse, ParsedImage } from "icojs";
import { getSystemTheme } from "./theme";
import { readFile } from "fs/promises";
@@ -71,7 +71,7 @@ export class AssetManager {
`${icon}${prefix}.ico`
);
const icoBuffer = await readFile(icoPath);
const images = await parseICO(icoBuffer, "image/png");
const images = await parse(icoBuffer, "image/png");
ALL_ICONS.push({ id: icon, images, prefix });
}
}

View File

@@ -20,22 +20,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { autoUpdater } from "electron-updater";
import { config } from "./config";
const CHANNEL = autoUpdater.currentVersion.raw.endsWith("-beta")
? "beta"
: "latest";
async function configureAutoUpdater() {
const releaseTrack =
config.releaseTrack === "stable" ? "latest" : config.releaseTrack;
autoUpdater.setFeedURL({
provider: "generic",
url: `https://notesnook.com/api/v1/releases/${process.platform}/${releaseTrack}`,
url: `https://notesnook.com/api/v1/releases/${process.platform}/${CHANNEL}`,
useMultipleRangeRequest: false,
channel: releaseTrack
channel: CHANNEL
});
autoUpdater.autoDownload = config.automaticUpdates;
autoUpdater.allowDowngrade =
// only allow downgrade if the current version is a prerelease
// and the user has changed the release track to stable
config.releaseTrack === "stable" &&
autoUpdater.currentVersion.prerelease.length > 0;
autoUpdater.allowDowngrade = false;
autoUpdater.allowPrerelease = false;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.disableWebInstaller = true;

View File

@@ -20,7 +20,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { nativeTheme } from "electron";
import { JSONStorage } from "./json-storage";
import { z } from "zod";
import { autoUpdater } from "electron-updater";
export const DesktopIntegration = z.object({
autoStart: z.boolean().optional(),
@@ -47,9 +46,6 @@ export const config = {
automaticUpdates: true,
proxyRules: "",
customDns: true,
releaseTrack: autoUpdater.currentVersion.raw.includes("-beta")
? "beta"
: "stable",
backgroundColor: nativeTheme.themeSource === "dark" ? "#0f0f0f" : "#ffffff",
windowControlsIconColor:

View File

@@ -29,7 +29,3 @@ export function isDevelopment() {
export function isFlatpak() {
return existsSync("/.flatpak-info");
}
export function isSnap() {
return process.env.SNAP !== undefined;
}

View File

@@ -17,7 +17,6 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { strings } from "@notesnook/intl";
import { Menu, MenuItem, clipboard, shell } from "electron";
function setupMenu() {
@@ -41,7 +40,7 @@ function setupMenu() {
if (params.misspelledWord) {
menu.append(
new MenuItem({
label: strings.addToDictionary(),
label: "Add to dictionary",
click: () =>
globalThis.window?.webContents.session.addWordToSpellCheckerDictionary(
params.misspelledWord
@@ -60,7 +59,7 @@ function setupMenu() {
if (params.linkURL.length) {
menu.append(
new MenuItem({
label: strings.openInBrowser(),
label: "Open in browser",
click: () => shell.openExternal(params.linkURL)
})
);
@@ -69,7 +68,7 @@ function setupMenu() {
if (params.isEditable) {
menu.append(
new MenuItem({
label: strings.undo(),
label: "Undo",
role: "undo",
enabled: params.isEditable,
accelerator: "CommandOrControl+Z"
@@ -78,7 +77,7 @@ function setupMenu() {
menu.append(
new MenuItem({
label: strings.redo(),
label: "Redo",
role: "redo",
enabled: params.isEditable,
accelerator: "CommandOrControl+Y"
@@ -95,7 +94,7 @@ function setupMenu() {
if (params.isEditable)
menu.append(
new MenuItem({
label: strings.cut(),
label: "Cut",
role: "cut",
enabled: params.selectionText.length > 0,
accelerator: "CommandOrControl+X"
@@ -105,7 +104,7 @@ function setupMenu() {
if (params.linkURL?.length) {
menu.append(
new MenuItem({
label: strings.copyLink(),
label: "Copy link",
click() {
clipboard.writeText(params.linkURL);
}
@@ -114,7 +113,7 @@ function setupMenu() {
menu.append(
new MenuItem({
label: strings.copyLinkText(),
label: "Copy link text",
click() {
clipboard.writeText(params.linkText);
}
@@ -125,7 +124,7 @@ function setupMenu() {
if (params.selectionText.length) {
menu.append(
new MenuItem({
label: strings.copy(),
label: "Copy",
role: "copy",
accelerator: "CommandOrControl+C"
})
@@ -136,51 +135,23 @@ function setupMenu() {
menu.append(
new MenuItem({
id: "copy-image",
label: strings.copyImage(),
label: "Copy Image",
click() {
globalThis.window?.webContents.copyImageAt(params.x, params.y);
}
})
);
if (params.isEditable) {
if (params.isEditable)
menu.append(
new MenuItem({
label: strings.paste(),
role: "paste",
label: "Paste",
role: "pasteAndMatchStyle",
enabled: clipboard.readText("clipboard").length > 0,
accelerator: "CommandOrControl+V"
})
);
menu.append(
new MenuItem({
label:
process.platform === "darwin"
? strings.pasteAndMatchStyle()
: strings.pasteWithoutFormatting(),
role: "pasteAndMatchStyle",
enabled: clipboard.readText("clipboard").length > 0,
accelerator:
process.platform === "darwin"
? "Option+Shift+Command+V"
: "Shift+CommandOrControl+V"
})
);
menu.append(
new MenuItem({
type: "separator"
})
);
menu.append(
new MenuItem({
label: strings.spellCheck(),
role: "toggleSpellChecker"
})
);
}
if (menu.items.length > 0) menu.popup();
});
}

View File

@@ -57,8 +57,7 @@ function getSystemTheme() {
nativeTheme.themeSource = oldThemeSource;
setTimeout(
() =>
listeners.forEach((a) => nativeTheme.addListener("updated", () => a())),
() => listeners.forEach((a) => nativeTheme.addListener("updated", a)),
1000
);
return currentTheme;
@@ -73,8 +72,7 @@ function changeTheme(theme: Theme) {
nativeTheme.themeSource = theme;
setTimeout(
() =>
listeners.forEach((a) => nativeTheme.addListener("updated", () => a())),
() => listeners.forEach((a) => nativeTheme.addListener("updated", a)),
1000
);
}

View File

@@ -2,8 +2,7 @@
"extends": "../../tsconfig",
"compilerOptions": {
"outDir": "./dist/",
"lib": ["ESNext"],
"moduleResolution": "Bundler"
"lib": ["ESNext"]
},
"include": ["src", "global.d.ts"]
}

View File

@@ -1,40 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
testTimeout: process.env.CI ? 120 * 1000 : 120 * 1000,
sequence: {
concurrent: true,
shuffle: true
},
dir: "./__tests__/",
exclude: [
"**/node_modules/**",
"**/dist/**",
"**/cypress/**",
"**/.{idea,git,cache,output,temp}/**",
"**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*",
"**/test-results/**",
"**/test-artifacts/**"
]
}
});

View File

@@ -38,8 +38,7 @@ DerivedData
*.ipa
*.xcuserstate
*.hprof
**/.xcode.env.local
native/cache
ios/.xcode.env.local
# Android/IntelliJ
#
rn-build-deps/
@@ -69,7 +68,6 @@ buck-out/
*/fastlane/report.xml
*/fastlane/Preview.html
*/fastlane/screenshots
native/vendor
# Bundle artifact
*.jsbundle

View File

@@ -16,33 +16,26 @@ GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import {
ScopedThemeProvider,
THEME_COMPATIBILITY_VERSION,
useThemeEngineStore
} from "@notesnook/theme";
import React, { useEffect } from "react";
import { Appearance, I18nManager, StatusBar } from "react-native";
import { I18nManager, View } from "react-native";
import "react-native-gesture-handler";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import DialogProvider from "./components/dialog-provider";
import AppLockedOverlay from "./components/app-lock-overlay";
import { withErrorBoundry } from "./components/exception-handler";
import GlobalSafeAreaProvider from "./components/globalsafearea";
import { Toast } from "./components/toast";
import { useAppEvents } from "./hooks/use-app-events";
import { NotePreviewConfigure } from "./screens/note-preview-configure";
import { RootNavigation } from "./navigation/navigation-stack";
import { ApplicationHolder } from "./navigation";
import { themeTrpcClient } from "./screens/settings/theme-selector";
import Notifications from "./services/notifications";
import SettingsService from "./services/settings";
import { TipManager } from "./services/tip-manager";
import { changeSystemBarColors, useThemeStore } from "./stores/use-theme-store";
import { useThemeStore } from "./stores/use-theme-store";
import { useUserStore } from "./stores/use-user-store";
import RNBootSplash from "react-native-bootsplash";
import AppLocked from "./components/app-lock";
I18nManager.allowRTL(false);
I18nManager.forceRTL(false);
@@ -52,13 +45,11 @@ if (appLockEnabled || appLockMode !== "none") {
useUserStore.getState().lockApp(true);
}
RNBootSplash.hide();
const App = (props: { configureMode: "note-preview" }) => {
const App = () => {
useAppEvents();
//@ts-ignore
globalThis["IS_MAIN_APP_RUNNING"] = true;
useEffect(() => {
changeSystemBarColors();
SettingsService.onFirstLaunch();
setTimeout(async () => {
await Notifications.get();
@@ -68,29 +59,37 @@ const App = (props: { configureMode: "note-preview" }) => {
TipManager.init();
}, 100);
}, []);
return (
<SafeAreaProvider>
<StatusBar translucent={true} backgroundColor="transparent" />
<View
style={{
height: "100%",
width: "100%"
}}
>
<View
style={{
position: "absolute",
width: "100%",
height: "100%",
zIndex: -1
}}
pointerEvents="none"
>
<SafeAreaProvider>
<GlobalSafeAreaProvider />
</SafeAreaProvider>
</View>
<GestureHandlerRootView
style={{
height: "100%",
width: "100%"
}}
>
<GlobalSafeAreaProvider />
{props.configureMode === "note-preview" ? (
<NotePreviewConfigure />
) : (
<RootNavigation />
)}
<ScopedThemeProvider value="dialog">
<Toast />
</ScopedThemeProvider>
<DialogProvider />
<AppLocked />
<ApplicationHolder />
</GestureHandlerRootView>
</SafeAreaProvider>
<AppLockedOverlay />
</View>
);
};
@@ -100,8 +99,8 @@ let currTheme =
: SettingsService.getProperty("lighTheme");
useThemeEngineStore.getState().setTheme(currTheme);
export const withTheme = (Element: (props: any) => JSX.Element) => {
return function AppWithThemeProvider(props: any) {
export const withTheme = (Element: () => JSX.Element) => {
return function AppWithThemeProvider() {
const [colorScheme, darkTheme, lightTheme] = useThemeStore((state) => [
state.colorScheme,
state.darkTheme,
@@ -120,42 +119,26 @@ export const withTheme = (Element: (props: any) => JSX.Element) => {
})
.then((theme) => {
if (theme) {
console.log(theme.version, "theme updated");
theme.colorScheme === "dark"
? useThemeStore.getState().setDarkTheme(theme)
: useThemeStore.getState().setLightTheme(theme);
}
})
.catch(() => {
/* empty */
});
.catch(console.log);
}, 1000);
const listener = Appearance.addChangeListener(({ colorScheme }) => {
if (colorScheme && SettingsService.getProperty("useSystemTheme")) {
useThemeStore.getState().setColorScheme(colorScheme);
}
});
return () => {
listener.remove();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const nextTheme = colorScheme === "dark" ? darkTheme : lightTheme;
if (JSON.stringify(nextTheme) !== JSON.stringify(currTheme)) {
useThemeEngineStore
.getState()
.setTheme(colorScheme === "dark" ? darkTheme : lightTheme);
currTheme = nextTheme;
}
}, [colorScheme, darkTheme, lightTheme]);
const nextTheme = colorScheme === "dark" ? darkTheme : lightTheme;
if (JSON.stringify(nextTheme) !== JSON.stringify(currTheme)) {
useThemeEngineStore
.getState()
.setTheme(colorScheme === "dark" ? darkTheme : lightTheme);
currTheme = nextTheme;
}
return (
<I18nProvider i18n={i18n}>
<Element {...props} />
</I18nProvider>
);
return <Element />;
};
};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,156 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Platform } from "react-native";
import { MMKV } from "../database/mmkv";
import { useSettingStore } from "../../stores/use-setting-store";
const WEBSITE_ID = "3c6890ce-8410-49d5-8831-15fb2eb28a21";
const baseUrl = "https://analytics.streetwriters.co/api/collect";
const UA =
Platform.OS === "ios"
? "Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1"
: `
Mozilla/5.0 (Linux; Android 8.0.0; SM-G960F Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.84 Mobile Safari/537.36`;
/**
*
* @param {Routes} route
* @param {Routes[]} conditions
* @returns
*/
async function canUpdateAnalytics(route, conditions = []) {
if (!useSettingStore?.getState()?.settings?.telemetry) return false;
let eventsList = MMKV.getString("notesnookUserEvents");
if (eventsList) {
eventsList = JSON.parse(eventsList);
}
if (eventsList && eventsList[route]) {
console.log("analytics: event already sent", route);
return false;
}
if (route !== "/welcome") {
for (let cond of conditions) {
if (!eventsList || !eventsList[cond]) {
console.log("analytics: conditions not met for event", route, cond);
return false;
}
}
}
console.log("analytics: will send event", route);
return true;
}
async function saveAnalytics(route, value = true) {
let eventsList = MMKV.getString("notesnookUserEvents");
if (eventsList) {
eventsList = JSON.parse(eventsList);
} else {
eventsList = {};
}
eventsList[route] = value;
MMKV.setString("notesnookUserEvents", JSON.stringify(eventsList));
}
/**
*@typedef {"/welcome" | "/home" | "/signup" | "/first-note" | "/account-created" | "/pro-sheet" | "/pro-plans" | "/iap-native" | "/pro-screen" | "/editor" | "/editor-toolbar" | "/properties" | "/sidemenu"} Routes
* @param {Routes} prevRoute
* @param {Routes} route
* @param {Routes[]} conditions
* @param {boolean} once
* @returns
*/
async function pageView(
route,
prevRoute = "",
conditions = ["/welcome"],
once = true
) {
if (__DEV__) return;
if (!(await canUpdateAnalytics(route, conditions)) && once) return;
let body = {
payload: {
website: WEBSITE_ID,
url: `notesnook-${Platform.OS}${prevRoute}${route}`,
referrer: `https://notesnook.com/notesnook-${Platform.OS}${prevRoute}`,
hostname: `notesnook-${Platform.OS}`,
language: "en-US",
screen: "1920x1080"
},
type: "pageview"
};
try {
let response = await fetch(baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": UA
},
body: JSON.stringify(body)
});
console.log("analytics: event sent", route);
await saveAnalytics(route);
return await response.text();
} catch (e) {
console.log(e);
}
}
async function sendEvent(type, value, once = true) {
if (__DEV__) return;
if (!(await canUpdateAnalytics(type)) && once) return;
let body = {
payload: {
website: WEBSITE_ID,
url: "/",
event_type: type,
event_value: value,
hostname: "notesnook-android-app",
language: "en-US",
screen: "1920x1080"
},
type: "event"
};
try {
let response = await fetch(baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": UA
},
body: JSON.stringify(body)
});
console.log(response.status);
return await response.text();
} catch (e) {
console.log(e);
}
}
export default {
sendEvent,
pageView,
saveAnalytics
};

View File

@@ -0,0 +1,385 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Sodium from "@ammarahmed/react-native-sodium";
import { Platform } from "react-native";
import "react-native-get-random-values";
import * as Keychain from "react-native-keychain";
import { MMKVLoader, ProcessingModes } from "react-native-mmkv-storage";
import { generateSecureRandom } from "react-native-securerandom";
import { DatabaseLogger } from ".";
import { MMKV } from "./mmkv";
import { ToastManager } from "../../services/event-manager";
// Database key cipher is persisted across different user sessions hence it has
// it's independent storage which we will never clear. This is only used when application has
// app lock with password enabled.
export const CipherStorage = new MMKVLoader()
.withInstanceID("cipher_storage")
.setProcessingMode(
Platform.OS === "ios"
? ProcessingModes.MULTI_PROCESS
: ProcessingModes.SINGLE_PROCESS
)
.disableIndexing()
.initialize();
const IOS_KEYCHAIN_ACCESS_GROUP = "group.org.streetwriters.notesnook";
const IOS_KEYCHAIN_SERVICE_NAME = "org.streetwriters.notesnook";
const KEYCHAIN_SERVER_DBKEY = "notesnook:db";
const NOTESNOOK_APPLOCK_KEY_SALT = "kBwr1Kre86ebOZ8ThLu2OA";
const NOTESNOOK_DB_KEY_SALT = "SNuzOcEK3amoqL0WvPeKqw";
const DB_KEY_CIPHER = "databaseKeyCipher";
const USER_KEY_CIPHER = "userKeyCipher";
const APPLOCK_CIPHER = "applockCipher";
const KEYSTORE_CONFIG = Platform.select({
ios: {
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
accessGroup: IOS_KEYCHAIN_ACCESS_GROUP,
service: IOS_KEYCHAIN_SERVICE_NAME
},
android: {}
});
function generatePassword() {
const length = 80;
const crypto = window.crypto || window.msCrypto;
if (typeof crypto === "undefined") {
throw new Error(
"Crypto API is not supported. Please upgrade your web browser"
);
}
const charset =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&+_{}[]():<>/?;";
const indexes = crypto.getRandomValues(new Uint32Array(length));
let secret = "";
for (const index of indexes) {
secret += charset[index % charset.length];
}
return secret;
}
export async function encryptDatabaseKeyWithPassword(appLockPassword) {
const key = getDatabaseKey();
const appLockCredentials = await Sodium.deriveKey(
appLockPassword,
NOTESNOOK_APPLOCK_KEY_SALT
);
const databaseKeyCipher = await encrypt(appLockCredentials, key);
MMKV.setMap(DB_KEY_CIPHER, databaseKeyCipher);
// We reset the database key from keychain once app lock password is set.
await Keychain.resetInternetCredentials(KEYCHAIN_SERVER_DBKEY);
return true;
}
export async function restoreDatabaseKeyToKeyChain(appLockPassword) {
const databaseKeyCipher = CipherStorage.getMap(DB_KEY_CIPHER);
const databaseKey = await decrypt(
{
password: appLockPassword
},
databaseKeyCipher
);
await Keychain.setInternetCredentials(
KEYCHAIN_SERVER_DBKEY,
"notesnook",
databaseKey,
KEYSTORE_CONFIG
);
MMKV.removeItem(DB_KEY_CIPHER);
return true;
}
export async function setAppLockVerificationCipher(appLockPassword) {
try {
const appLockCredentials = await Sodium.deriveKey(
appLockPassword,
NOTESNOOK_APPLOCK_KEY_SALT
);
const encrypted = await encrypt(appLockCredentials, generatePassword());
CipherStorage.setMap(APPLOCK_CIPHER, encrypted);
DatabaseLogger.info("setAppLockVerificationCipher");
} catch (e) {
DatabaseLogger.error(e);
console.log(e);
}
}
export async function clearAppLockVerificationCipher() {
CipherStorage.removeItem(APPLOCK_CIPHER);
}
export async function validateAppLockPassword(appLockPassword) {
try {
const appLockCipher = CipherStorage.getMap(APPLOCK_CIPHER);
if (!appLockCipher) return true;
const key = await Sodium.deriveKey(appLockPassword, appLockCipher.salt);
const decrypted = await decrypt(key, appLockCipher);
DatabaseLogger.info(
`validateAppLockPassword: ${typeof decrypted === "string"}`
);
return typeof decrypted === "string";
} catch (e) {
DatabaseLogger.error(e);
return false;
}
}
let DB_KEY;
export function clearDatabaseKey() {
DB_KEY = undefined;
DatabaseLogger.info("Cleared database key");
}
export async function getDatabaseKey(appLockPassword) {
if (DB_KEY) return DB_KEY;
try {
if (appLockPassword) {
const databaseKeyCipher = CipherStorage.getMap("databaseKeyCipher");
const databaseKey = await decrypt(
{
password: appLockPassword
},
databaseKeyCipher
);
DatabaseLogger.info("Getting database key from cipher");
DB_KEY = databaseKey;
}
if (!DB_KEY) {
const hasKey = await Keychain.hasInternetCredentials(
KEYCHAIN_SERVER_DBKEY
);
if (hasKey) {
let credentials = await Keychain.getInternetCredentials(
KEYCHAIN_SERVER_DBKEY,
KEYSTORE_CONFIG
);
DatabaseLogger.info("Getting database key from Keychain");
DB_KEY = credentials.password;
}
}
if (!DB_KEY) {
DatabaseLogger.info("Generating new database key");
const password = generatePassword();
const derivedDatabaseKey = await Sodium.deriveKey(
password,
NOTESNOOK_DB_KEY_SALT
);
DB_KEY = derivedDatabaseKey.key;
await Keychain.setInternetCredentials(
KEYCHAIN_SERVER_DBKEY,
"notesnook",
DB_KEY,
KEYSTORE_CONFIG
);
}
if (await Keychain.hasInternetCredentials("notesnook")) {
const userKeyCredentials = await Keychain.getInternetCredentials(
"notesnook",
KEYSTORE_CONFIG
);
if (userKeyCredentials) {
const userKeyCipher = await encrypt(
{
key: DB_KEY,
salt: NOTESNOOK_DB_KEY_SALT
},
userKeyCredentials.password
);
// Store encrypted user key in MMKV
MMKV.setMap(USER_KEY_CIPHER, userKeyCipher);
await Keychain.resetInternetCredentials("notesnook");
}
DatabaseLogger.info("Migrated user credentials to cipher storage");
}
return DB_KEY;
} catch (e) {
ToastManager.error(e, "Error getting database key");
console.log(e, "error");
DatabaseLogger.error(e);
return null;
}
}
export async function deriveCryptoKey(data) {
try {
let credentials = await Sodium.deriveKey(data.password, data.salt);
const userKeyCipher = await encrypt(
{
key: await getDatabaseKey(),
salt: NOTESNOOK_DB_KEY_SALT
},
credentials.key
);
DatabaseLogger.info("User key stored: ", !!userKeyCipher);
// Store encrypted user key in MMKV
MMKV.setMap(USER_KEY_CIPHER, userKeyCipher);
return credentials.key;
} catch (e) {
DatabaseLogger.error(e);
}
}
export async function getCryptoKey(_name) {
try {
const keyCipher = MMKV.getMap(USER_KEY_CIPHER);
if (!keyCipher) {
DatabaseLogger.info("User key cipher is null");
return null;
}
const key = await decrypt(
{
key: await getDatabaseKey(),
salt: keyCipher.salt
},
keyCipher
);
return key;
} catch (e) {
console.log("getCryptoKey", e);
DatabaseLogger.error(e);
}
}
export async function removeCryptoKey(_name) {
try {
MMKV.removeItem(USER_KEY_CIPHER);
await Keychain.resetInternetCredentials("notesnook");
return true;
} catch (e) {
DatabaseLogger.error(e);
}
}
export async function getRandomBytes(length) {
return await generateSecureRandom(length);
}
export async function hash(password, email) {
let result = await Sodium.hashPassword(password, email);
return result;
}
export async function generateCryptoKey(password, salt) {
try {
let credentials = await Sodium.deriveKey(password, salt || null);
return credentials;
} catch (e) {
DatabaseLogger.error(e);
}
}
export function getAlgorithm(base64Variant) {
return `xcha-argon2i13-${base64Variant}`;
}
export async function decrypt(password, data) {
if (!password.password && !password.key) return undefined;
if (password.password && password.password === "" && !password.key)
return undefined;
let _data = { ...data };
_data.output = "plain";
if (!password.salt) password.salt = data.salt;
return await Sodium.decrypt(password, _data);
}
export async function decryptMulti(password, data) {
if (!password.password && !password.key) return undefined;
if (password.password && password.password === "" && !password.key)
return undefined;
data = data.map((d) => {
d.output = "plain";
return d;
});
if (data.length && !password.salt) {
password.salt = data[0].salt;
}
return await Sodium.decryptMulti(password, data);
}
export function parseAlgorithm(alg) {
if (!alg) return {};
const [enc, kdf, compressed, compressionAlg, base64variant] = alg.split("-");
return {
encryptionAlgorithm: enc,
kdfAlgorithm: kdf,
compressionAlgorithm: compressionAlg,
isCompress: compressed === "1",
base64_variant: base64variant
};
}
export async function encrypt(password, data) {
if (!password.password && !password.key) return undefined;
if (password.password && password.password === "" && !password.key)
return undefined;
let message = {
type: "plain",
data: data
};
let result = await Sodium.encrypt(password, message);
return {
...result,
alg: getAlgorithm(7)
};
}
export async function encryptMulti(password, data) {
if (!password.password && !password.key) return undefined;
if (password.password && password.password === "" && !password.key)
return undefined;
let results = await Sodium.encryptMulti(
password,
data.map((item) => ({
type: "plain",
data: item
}))
);
return !results
? []
: results.map((result) => ({
...result,
alg: getAlgorithm(7)
}));
}

View File

@@ -1,473 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Sodium, { Cipher, Password } from "@ammarahmed/react-native-sodium";
import { SerializedKey } from "@notesnook/crypto";
import { Platform } from "react-native";
import "react-native-get-random-values";
import * as Keychain from "react-native-keychain";
import { MMKVLoader, ProcessingModes } from "react-native-mmkv-storage";
import { generateSecureRandom } from "react-native-securerandom";
import { DatabaseLogger } from ".";
import { ToastManager } from "../../services/event-manager";
import { MMKV } from "./mmkv";
// Database key cipher is persisted across different user sessions hence it has
// it's independent storage which we will never clear. This is only used when application has
// app lock with password enabled.
export const CipherStorage = new MMKVLoader()
.withInstanceID("cipher_storage")
.setProcessingMode(
Platform.OS === "ios"
? ProcessingModes.MULTI_PROCESS
: ProcessingModes.SINGLE_PROCESS
)
.disableIndexing()
.initialize();
const IOS_KEYCHAIN_ACCESS_GROUP = "group.org.streetwriters.notesnook";
const IOS_KEYCHAIN_SERVICE_NAME = "org.streetwriters.notesnook";
const KEYCHAIN_SERVER_DBKEY = "notesnook:db";
const NOTESNOOK_APPLOCK_KEY_SALT = "kBwr1Kre86ebOZ8ThLu2OA";
const NOTESNOOK_DB_KEY_SALT = "SNuzOcEK3amoqL0WvPeKqw";
const DB_KEY_CIPHER = "databaseKeyCipher";
const USER_KEY_CIPHER = "userKeyCipher";
const APPLOCK_CIPHER = "applockCipher";
const KEYSTORE_CONFIG = Platform.select({
ios: {
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
accessGroup: IOS_KEYCHAIN_ACCESS_GROUP,
service: IOS_KEYCHAIN_SERVICE_NAME
},
android: {}
});
function generatePassword() {
const length = 80;
//@ts-ignore
const crypto = window.crypto || window.msCrypto;
if (typeof crypto === "undefined") {
throw new Error(
"Crypto API is not supported. Please upgrade your web browser"
);
}
const charset =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&+_{}[]():<>/?;";
const indexes = crypto.getRandomValues(new Uint32Array(length));
let secret = "";
for (const index of indexes) {
secret += charset[index % charset.length];
}
return secret;
}
export async function encryptDatabaseKeyWithPassword(appLockPassword: string) {
const key = (await getDatabaseKey()) as string;
const appLockCredentials = await Sodium.deriveKey(
appLockPassword,
NOTESNOOK_APPLOCK_KEY_SALT
);
const databaseKeyCipher = (await encrypt(appLockCredentials, key)) as Cipher;
MMKV.setMap(DB_KEY_CIPHER, databaseKeyCipher);
// We reset the database key from keychain once app lock password is set.
await Keychain.resetInternetCredentials(KEYCHAIN_SERVER_DBKEY);
return true;
}
export async function restoreDatabaseKeyToKeyChain(appLockPassword: string) {
const databaseKeyCipher: Cipher = CipherStorage.getMap(DB_KEY_CIPHER);
const databaseKey = (await decrypt(
{
password: appLockPassword
},
databaseKeyCipher
)) as string;
await Keychain.setInternetCredentials(
KEYCHAIN_SERVER_DBKEY,
"notesnook",
databaseKey,
KEYSTORE_CONFIG
);
MMKV.removeItem(DB_KEY_CIPHER);
return true;
}
export async function setAppLockVerificationCipher(appLockPassword: string) {
try {
const appLockCredentials = await Sodium.deriveKey(
appLockPassword,
NOTESNOOK_APPLOCK_KEY_SALT
);
const encrypted = (await encrypt(
appLockCredentials,
generatePassword()
)) as Cipher;
CipherStorage.setMap(APPLOCK_CIPHER, encrypted);
DatabaseLogger.info("setAppLockVerificationCipher");
} catch (e) {
DatabaseLogger.error(e);
}
}
export async function clearAppLockVerificationCipher() {
CipherStorage.removeItem(APPLOCK_CIPHER);
}
export async function validateAppLockPassword(appLockPassword: string) {
try {
const appLockCipher: Cipher = CipherStorage.getMap(APPLOCK_CIPHER);
if (!appLockCipher) return true;
const key = await Sodium.deriveKey(appLockPassword, appLockCipher.salt);
const decrypted = await decrypt(key, appLockCipher);
DatabaseLogger.info(
`validateAppLockPassword: ${typeof decrypted === "string"}`
);
return typeof decrypted === "string";
} catch (e) {
DatabaseLogger.error(e);
return false;
}
}
let DB_KEY: string | undefined;
export function clearDatabaseKey() {
DB_KEY = undefined;
DatabaseLogger.info("Cleared database key");
}
export async function getDatabaseKey(appLockPassword?: string) {
if (DB_KEY) return DB_KEY;
if (appLockPassword) {
const databaseKeyCipher: Cipher = CipherStorage.getMap("databaseKeyCipher");
const databaseKey = await decrypt(
{
password: appLockPassword
},
databaseKeyCipher
);
DatabaseLogger.info("Getting database key from cipher");
DB_KEY = databaseKey;
}
if (!DB_KEY) {
const hasKey = await Keychain.hasInternetCredentials(KEYCHAIN_SERVER_DBKEY);
if (hasKey) {
const credentials = await Keychain.getInternetCredentials(
KEYCHAIN_SERVER_DBKEY
);
DatabaseLogger.info("Getting database key from Keychain");
DB_KEY = (credentials as Keychain.UserCredentials).password;
}
}
if (!DB_KEY) {
DatabaseLogger.info("Generating new database key");
const password = generatePassword();
const derivedDatabaseKey = await Sodium.deriveKey(
password,
NOTESNOOK_DB_KEY_SALT
);
DB_KEY = derivedDatabaseKey.key as string;
await Keychain.setInternetCredentials(
KEYCHAIN_SERVER_DBKEY,
"notesnook",
DB_KEY,
KEYSTORE_CONFIG
);
}
if (await Keychain.hasInternetCredentials("notesnook")) {
const userKeyCredentials = await Keychain.getInternetCredentials(
"notesnook"
);
if (userKeyCredentials) {
const userKeyCipher: Cipher = (await encrypt(
{
key: DB_KEY,
salt: NOTESNOOK_DB_KEY_SALT
},
userKeyCredentials.password
)) as Cipher;
// Store encrypted user key in MMKV
MMKV.setMap(USER_KEY_CIPHER, userKeyCipher);
await Keychain.resetInternetCredentials("notesnook");
}
DatabaseLogger.info("Migrated user credentials to cipher storage");
}
if (!DB_KEY) {
throw new Error(
`Failed to get database key, ${await Keychain.hasInternetCredentials(
KEYCHAIN_SERVER_DBKEY
)}`
);
}
return DB_KEY;
}
export async function deriveCryptoKeyFallback(data: SerializedKey) {
if (Platform.OS !== "ios") return;
try {
if (!data.password || !data.salt)
throw new Error(
"Invalid password and salt provided to deriveCryptoKeyFallback"
);
const credentials = await Sodium.deriveKeyFallback?.(
data.password,
data.salt
);
if (!credentials) return;
const userKeyCipher = (await encrypt(
{
key: (await getDatabaseKey()) as string,
salt: NOTESNOOK_DB_KEY_SALT
},
credentials.key as string
)) as Cipher<"base64">;
DatabaseLogger.info("User key fallback stored: ", {
userKeyCipher: !!userKeyCipher
});
// Store encrypted user key in MMKV
MMKV.setMap(USER_KEY_CIPHER, userKeyCipher);
} catch (e) {
DatabaseLogger.error(e);
}
}
export async function deriveCryptoKey(data: SerializedKey) {
try {
if (!data.password || !data.salt)
throw new Error("Invalid password and salt provided to deriveCryptoKey");
const credentials = (await Sodium.deriveKey(
data.password,
data.salt
)) as Password;
const userKeyCipher = (await encrypt(
{
key: (await getDatabaseKey()) as string,
salt: NOTESNOOK_DB_KEY_SALT
},
credentials.key as string
)) as Cipher<"base64">;
DatabaseLogger.info("User key stored: ", {
userKeyCipher: !!userKeyCipher
});
// Store encrypted user key in MMKV
MMKV.setMap(USER_KEY_CIPHER, userKeyCipher);
} catch (e) {
DatabaseLogger.error(e);
}
}
export async function getCryptoKey() {
try {
const keyCipher: Cipher = MMKV.getMap(USER_KEY_CIPHER);
if (!keyCipher) {
DatabaseLogger.info("User key cipher is null");
return undefined;
}
const key = await decrypt(
{
key: (await getDatabaseKey()) as string,
salt: keyCipher.salt
},
keyCipher
);
return key;
} catch (e) {
DatabaseLogger.error(e);
}
}
export async function removeCryptoKey() {
try {
MMKV.removeItem(USER_KEY_CIPHER);
await Keychain.resetInternetCredentials("notesnook");
return true;
} catch (e) {
DatabaseLogger.error(e);
}
}
export async function getRandomBytes(length: number) {
return await generateSecureRandom(length);
}
export async function hash(
password: string,
email: string,
options?: { usesFallback?: boolean }
) {
DatabaseLogger.log(`Hashing password: fallback: ${options?.usesFallback}`);
if (options?.usesFallback && Platform.OS !== "ios") {
return "";
}
return (
options?.usesFallback
? await Sodium.hashPasswordFallback?.(password, email)
: await Sodium.hashPassword(password, email)
) as string;
}
export async function generateCryptoKey(password: string, salt?: string) {
return Sodium.deriveKey(password, salt) as Promise<SerializedKey>;
}
export async function generateCryptoKeyFallback(
password: string,
salt?: string
): Promise<SerializedKey> {
return Sodium.deriveKeyFallback?.(
password,
salt as string
) as Promise<SerializedKey>;
}
export function getAlgorithm(base64Variant: number) {
return `xcha-argon2i13-${base64Variant}`;
}
export async function decrypt(password: SerializedKey, data: Cipher<"base64">) {
const _data = { ...data };
_data.output = "plain";
if (!password.salt) password.salt = data.salt;
if (Platform.OS === "ios" && !password.key && password.password) {
const key = await Sodium.deriveKey(password.password, password.salt);
try {
return await Sodium.decrypt(key, _data);
} catch (e) {
const fallbackKey = await Sodium.deriveKeyFallback?.(
password.password,
password.salt
);
if (Platform.OS === "ios" && fallbackKey) {
DatabaseLogger.info("Using fallback key for decryption");
}
if (fallbackKey) {
return await Sodium.decrypt(fallbackKey, _data);
} else {
throw e;
}
}
}
return await Sodium.decrypt(password, _data);
}
export async function decryptMulti(
password: Password,
data: Cipher<"base64">[]
) {
data = data.map((d) => {
d.output = "plain";
return d;
});
if (data.length && !password.salt) {
password.salt = data[0].salt;
}
if (Platform.OS === "ios" && !password.key && password.password) {
const key = await Sodium.deriveKey(password.password, password.salt);
try {
return await Sodium.decryptMulti(key, data);
} catch (e) {
const fallbackKey = await Sodium.deriveKeyFallback?.(
password.password,
password.salt as string
);
if (Platform.OS === "ios" && fallbackKey) {
DatabaseLogger.info("Using fallback key for decryption");
}
if (fallbackKey) {
return await Sodium.decryptMulti(fallbackKey, data);
} else {
throw e;
}
}
}
return await Sodium.decryptMulti(password, data);
}
export function parseAlgorithm(alg: string) {
if (!alg) return {};
const [enc, kdf, compressed, compressionAlg, base64variant] = alg.split("-");
return {
encryptionAlgorithm: enc,
kdfAlgorithm: kdf,
compressionAlgorithm: compressionAlg,
isCompress: compressed === "1",
base64_variant: base64variant
};
}
export async function encrypt(password: SerializedKey, plainText: string) {
const result = await Sodium.encrypt<"base64">(password, {
type: "plain",
data: plainText
});
return {
...result,
alg: getAlgorithm(7)
};
}
export async function encryptMulti(
password: SerializedKey,
plainText: string[]
) {
const results = await Sodium.encryptMulti<"base64">(
password,
plainText.map((item) => ({
type: "plain",
data: item
}))
);
return !results
? []
: results.map((result) => ({
...result,
alg: getAlgorithm(7)
}));
}

View File

@@ -16,50 +16,57 @@ GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import "./logger";
import { database } from "@notesnook/common";
import { logger as dbLogger, ICompressor } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import {
SqliteAdapter,
SqliteIntrospector,
SqliteQueryCompiler
} from "@streetwriters/kysely";
import { logger as dbLogger } from "@notesnook/core/dist/logger";
import { Platform } from "react-native";
import * as Gzip from "react-native-gzip";
import SettingsService from "../../services/settings";
import EventSource from "../../utils/sse/even-source-ios";
import AndroidEventSource from "../../utils/sse/event-source";
import { FileStorage } from "../filesystem";
import { getDatabaseKey } from "./encryption";
import "./logger";
import { SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler } from "kysely";
import filesystem from "../filesystem";
import Storage from "./storage";
import { RNSqliteDriver } from "./sqlite.kysely";
import { Storage } from "./storage";
import { getDatabaseKey } from "./encryption";
export async function setupDatabase(password?: string) {
database.host(
__DEV__
? {
API_HOST: "https://api.notesnook.com",
AUTH_HOST: "https://auth.streetwriters.co",
SSE_HOST: "https://events.streetwriters.co",
SUBSCRIPTIONS_HOST: "https://subscriptions.streetwriters.co",
ISSUES_HOST: "https://issues.streetwriters.co"
// API_HOST: "http://192.168.43.5:5264",
// AUTH_HOST: "http://192.168.43.5:8264",
// SSE_HOST: "http://192.168.43.5:7264",
// SUBSCRIPTIONS_HOST: "http://192.168.43.5:9264",
// ISSUES_HOST: "http://192.168.43.5:2624"
}
: {
API_HOST: "https://api.notesnook.com",
AUTH_HOST: "https://auth.streetwriters.co",
SSE_HOST: "https://events.streetwriters.co",
SUBSCRIPTIONS_HOST: "https://subscriptions.streetwriters.co",
ISSUES_HOST: "https://issues.streetwriters.co"
}
);
export async function setupDatabase(password) {
const key = await getDatabaseKey(password);
if (!key) throw new Error(strings.databaseSetupFailed());
if (!key)
throw new Error("Database setup failed, could not get database key");
database.host({
API_HOST: "https://api.notesnook.com",
AUTH_HOST: "https://auth.streetwriters.co",
SSE_HOST: "https://events.streetwriters.co",
SUBSCRIPTIONS_HOST: "https://subscriptions.streetwriters.co",
ISSUES_HOST: "https://issues.streetwriters.co",
MONOGRAPH_HOST: "https://monogr.ph",
...(SettingsService.getProperty("serverUrls") || {})
});
console.log("Opening database with key:", !!key);
database.setup({
storage: Storage,
eventsource: (Platform.OS === "ios"
? EventSource
: AndroidEventSource) as any,
fs: FileStorage,
compressor: async () =>
({
compress: Gzip.deflate,
decompress: Gzip.inflate
} as ICompressor),
eventsource: Platform.OS === "ios" ? EventSource : AndroidEventSource,
fs: filesystem,
compressor: {
compress: Gzip.deflate,
decompress: Gzip.inflate
},
batchSize: 100,
sqliteOptions: {
dialect: (name) => ({

View File

@@ -17,12 +17,8 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { initialize } from "@notesnook/core";
import {
SqliteAdapter,
SqliteIntrospector,
SqliteQueryCompiler
} from "@streetwriters/kysely";
import { initialize } from "@notesnook/core/dist/logger";
import { SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler } from "kysely";
import { Platform } from "react-native";
import { setLogger } from ".";
import { RNSqliteDriver } from "./sqlite.kysely";

View File

@@ -17,16 +17,11 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import type {
DatabaseConnection,
Driver,
QueryResult
} from "@streetwriters/kysely";
import { CompiledQuery } from "@streetwriters/kysely";
import type { DatabaseConnection, Driver, QueryResult } from "kysely";
import { CompiledQuery } from "kysely";
import { QuickSQLiteConnection, open } from "react-native-quick-sqlite";
import { strings } from "@notesnook/intl";
type Config = { dbName: string; async: boolean; location?: string };
type Config = { dbName: string; async: boolean; location: string };
export class RNSqliteDriver implements Driver {
private connection?: DatabaseConnection;
@@ -98,7 +93,7 @@ class RNSqliteConnection implements DatabaseConnection {
constructor(private readonly db: QuickSQLiteConnection) {}
streamQuery<R>(): AsyncIterableIterator<QueryResult<R>> {
throw new Error(strings.streamingNotSupported());
throw new Error("wasqlite driver doesn't support streaming");
}
async executeQuery<R>(

View File

@@ -16,52 +16,58 @@ GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { IStorage } from "@notesnook/core";
import { MMKVInstance } from "react-native-mmkv-storage";
import { Platform } from "react-native";
import RNFetchBlob from "react-native-blob-util";
import {
decrypt,
decryptMulti,
deriveCryptoKey,
deriveCryptoKeyFallback,
encrypt,
encryptMulti,
generateCryptoKey,
getCryptoKey,
getRandomBytes,
hash,
generateCryptoKeyFallback
removeCryptoKey,
decryptMulti,
encryptMulti
} from "./encryption";
import { MMKV } from "./mmkv";
export class KV {
storage: MMKVInstance;
constructor(storage: MMKVInstance) {
/**
* @type {typeof MMKV}
*/
storage = null;
constructor(storage) {
this.storage = storage;
}
async read<T>(key: string, isArray?: boolean) {
if (!key) return undefined;
const data = this.storage.getString(key);
if (!data) return undefined;
async read(key) {
if (!key) return null;
let data = this.storage.getString(key);
if (!data) return null;
try {
return JSON.parse(data) as T;
let parse = JSON.parse(data);
return parse;
} catch (e) {
return data as T;
return data;
}
}
async write<T>(key: string, data: T) {
async write(key, data) {
this.storage.setString(
key,
typeof data === "string" ? data : JSON.stringify(data)
);
return true;
}
async readMulti<T>(keys: string[]) {
async readMulti(keys) {
if (keys.length <= 0) {
return [];
} else {
try {
const data = await this.storage.getMultipleItemsAsync<any>(
let data = await this.storage.getMultipleItemsAsync(
keys.slice(),
"string"
);
@@ -73,24 +79,24 @@ export class KV {
obj = value;
}
return [key, obj];
}) as [string, T][];
});
} catch (e) {
return [];
console.log(e);
}
}
}
async remove(key: string) {
this.storage.removeItem(key);
async remove(key) {
return this.storage.removeItem(key);
}
async removeMulti(keys: string[]) {
if (!keys) return;
this.storage.removeItems(keys);
async removeMulti(keys) {
if (!keys) return true;
return this.storage.removeItems(keys);
}
async clear() {
this.storage.clearStore();
return this.storage.clearStore();
}
async getAllKeys() {
@@ -107,46 +113,54 @@ export class KV {
return keys;
}
async writeMulti(items: [string, any][]) {
await this.storage.setMultipleItemsAsync(items, "object");
async writeMulti(items) {
return this.storage.setMultipleItemsAsync(items, "object");
}
}
const DefaultStorage = new KV(MMKV);
export const Storage: IStorage = {
write<T>(key: string, data: T): Promise<void> {
return DefaultStorage.write(key, data);
},
writeMulti<T>(entries: [string, T][]): Promise<void> {
return DefaultStorage.writeMulti(entries);
},
readMulti<T>(keys: string[]): Promise<[string, T][]> {
return DefaultStorage.readMulti(keys);
},
read<T>(key: string, isArray?: boolean): Promise<T | undefined> {
return DefaultStorage.read(key, isArray);
},
remove(key: string): Promise<void> {
return DefaultStorage.remove(key);
},
removeMulti(keys: string[]): Promise<void> {
return DefaultStorage.removeMulti(keys);
},
clear(): Promise<void> {
return DefaultStorage.clear();
},
getAllKeys(): Promise<string[]> {
return DefaultStorage.getAllKeys();
},
hash,
getCryptoKey,
async function requestPermission() {
if (Platform.OS === "ios") return true;
return true;
}
async function checkAndCreateDir(path) {
let dir =
Platform.OS === "ios"
? RNFetchBlob.fs.dirs.DocumentDir + path
: RNFetchBlob.fs.dirs.SDCardDir + "/Notesnook/" + path;
try {
let exists = await RNFetchBlob.fs.exists(dir);
let isDir = await RNFetchBlob.fs.isDir(dir);
if (!exists || !isDir) {
await RNFetchBlob.fs.mkdir(dir);
}
} catch (e) {
await RNFetchBlob.fs.mkdir(dir);
}
return dir;
}
export default {
read: (key) => DefaultStorage.read(key),
write: (key, value) => DefaultStorage.write(key, value),
readMulti: (keys) => DefaultStorage.readMulti(keys),
remove: (key) => DefaultStorage.remove(key),
clear: () => DefaultStorage.clear(),
getAllKeys: () => DefaultStorage.getAllKeys(),
writeMulti: (items) => DefaultStorage.writeMulti(items),
removeMulti: (keys) => DefaultStorage.removeMulti(keys),
encrypt,
encryptMulti,
decrypt,
decryptMulti,
getRandomBytes,
checkAndCreateDir,
requestPermission,
deriveCryptoKey,
getCryptoKey,
removeCryptoKey,
hash,
generateCryptoKey,
generateCryptoKeyFallback,
deriveCryptoKeyFallback
encryptMulti
};

View File

@@ -20,6 +20,7 @@ import { Dimensions, Image, Platform } from "react-native";
import ImageResizer from "@bam.tech/react-native-image-resizer";
import RNFetchBlob from "react-native-blob-util";
console.log("hello world");
/**
* Scale down & compress images to screen width
* for loading in editor.
@@ -27,9 +28,13 @@ import RNFetchBlob from "react-native-blob-util";
*/
export async function compressToBase64(path, type) {
const { width: screenWidth, scale } = Dimensions.get("window");
console.log("COMPRESSING TO BASE64...");
return new Promise((resolve) => {
console.log(path, "image path...");
Image.getSize(path, async (width) => {
console.log("image width", width);
const response = await ImageResizer.createResizedImage(
path,
screenWidth * scale,
@@ -51,12 +56,10 @@ export async function compressToBase64(path, type) {
: response.uri,
"base64"
);
RNFetchBlob.fs.unlink(path.replace("file://", "")).catch(() => {
/* empty */
});
RNFetchBlob.fs.unlink(response.uri.replace("file://", "")).catch(() => {
/* empty */
});
RNFetchBlob.fs.unlink(path.replace("file://", "")).catch(console.log);
RNFetchBlob.fs
.unlink(response.uri.replace("file://", ""))
.catch(console.log);
resolve(base64);
});
@@ -78,8 +81,6 @@ export async function compressToFile(path, type) {
onlyScaleDown: true
}
);
RNFetchBlob.fs.unlink(path.replace("file://", "")).catch(() => {
/* empty */
});
RNFetchBlob.fs.unlink(path.replace("file://", "")).catch(console.log);
return response.uri;
}

View File

@@ -0,0 +1,304 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Sodium from "@ammarahmed/react-native-sodium";
import {
getFileNameWithExtension,
isImage,
isDocument
} from "@notesnook/core/dist/utils/filename";
import React from "react";
import { Platform } from "react-native";
import * as ScopedStorage from "react-native-scoped-storage";
import { subscribe, zip } from "react-native-zip-archive";
import RNFetchBlob from "react-native-blob-util";
import { ShareComponent } from "../../components/sheets/export-notes/share";
import { ToastManager, presentSheet } from "../../services/event-manager";
import { useAttachmentStore } from "../../stores/use-attachment-store";
import { DatabaseLogger, db } from "../database";
import Storage from "../database/storage";
import { cacheDir, copyFileAsync, releasePermissions } from "./utils";
import { createCacheDir, exists } from "./io";
import { IOS_APPGROUPID } from "../../utils/constants";
export const FileDownloadStatus = {
Success: 1,
Fail: 0
};
/**
* Download all user's attachments
* @returns
*/
export async function downloadAllAttachments() {
const attachments = await db.attachments.all.ids();
return downloadAttachments(attachments);
}
/**
* Downloads provided attachments to a .zip file
* on user's device.
* @param {string[]} attachments
* @param onProgress
* @returns
*/
export async function downloadAttachments(
attachments,
onProgress,
canceled,
groupId
) {
await createCacheDir();
if (!attachments || !attachments.length) return;
const result = new Map();
let outputFolder;
if (Platform.OS === "android") {
// Ask the user to select a directory to store the file
let file = await ScopedStorage.openDocumentTree(true);
outputFolder = file.uri;
if (!outputFolder) return;
} else {
outputFolder = await Storage.checkAndCreateDir("/downloads/");
}
// Create the folder to zip;
const zipSourceFolder = `${cacheDir}/notesnook-attachments`;
const zipOutputFile =
Platform.OS === "ios"
? `${outputFolder}/notesnook-attachments-${Date.now()}.zip`
: `${cacheDir}/notesnook-attachments.zip`;
if (await RNFetchBlob.fs.exists(zipSourceFolder)) {
await RNFetchBlob.fs.unlink(zipSourceFolder);
}
await RNFetchBlob.fs.mkdir(zipSourceFolder);
for (let i = 0; i < attachments.length; i++) {
let attachment = await db.attachments.attachment(attachments[i]);
const hash = attachment.hash;
try {
if (canceled.current) {
RNFetchBlob.fs.unlink(zipSourceFolder).catch(console.log);
return;
}
onProgress?.(
i + 1 / attachments.length,
`Downloading attachments (${i + 1}/${
attachments.length
})... Please wait`
);
// Download to cache
let uri = await downloadAttachment(hash, false, {
silent: true,
cache: true,
groupId: groupId
});
if (canceled.current) {
RNFetchBlob.fs.unlink(zipSourceFolder).catch(console.log);
return;
}
if (!uri) throw new Error("Failed to download file");
// Move file to the source folder we will zip eventually and rename the file to it's actual name.
const filePath = `${zipSourceFolder}/${attachment.filename}`;
await RNFetchBlob.fs.mv(`${cacheDir}/${uri}`, filePath);
result.set(hash, {
filename: attachment.filename,
status: FileDownloadStatus.Success,
attachment: attachment
});
} catch (e) {
result.set(hash, {
filename: attachment.filename,
status: FileDownloadStatus.Fail,
reason: e
});
ToastManager.error(e, "Error downloading attachment");
}
}
if (canceled.current) {
RNFetchBlob.fs.unlink(zipSourceFolder).catch(console.log);
return;
}
if (result?.size) {
let sub;
try {
onProgress?.(0, `Zipping... Please wait`);
// If all goes well, zip the notesnook-attachments folder in cache.
sub = subscribe(({ progress }) => {
onProgress(
progress,
`Saving zip file (${(progress * 100).toFixed(1)}%)... Please wait`
);
});
await zip(zipSourceFolder, zipOutputFile);
sub?.remove();
onProgress(1, `Saving zip file... Please wait`);
if (Platform.OS === "android") {
// Move the zip to user selected directory.
const file = await ScopedStorage.createFile(
outputFolder,
`notesnook-attachments-${Date.now()}.zip`,
"application/zip"
);
await copyFileAsync(`file://${zipOutputFile}`, file.uri);
}
onProgress?.(1, `Done`);
releasePermissions(outputFolder);
} catch (e) {
releasePermissions(outputFolder);
sub?.remove();
ToastManager.error(e, "Error zipping attachments");
}
// Remove source & zip file from cache.
RNFetchBlob.fs.unlink(zipSourceFolder).catch(console.log);
if (Platform.OS === "android") {
RNFetchBlob.fs.unlink(zipOutputFile).catch(console.log);
}
}
return result;
}
export default async function downloadAttachment(
hash,
global = true,
options = {
silent: false,
cache: false,
throwError: false,
groupId: undefined,
base64: false,
text: false
}
) {
await createCacheDir();
let attachment = await db.attachments.attachment(hash);
if (!attachment) {
DatabaseLogger.log("Attachment not found");
return;
}
let folder = {};
if (!options.cache) {
if (Platform.OS === "android") {
folder = await ScopedStorage.openDocumentTree();
if (!folder) return;
} else {
folder.uri = await Storage.checkAndCreateDir("/downloads/");
}
}
try {
await db
.fs()
.downloadFile(options.groupId || attachment.hash, attachment.hash);
if (!(await exists(attachment.hash))) {
DatabaseLogger.log("Attachment does not exist after download.");
return;
}
if (options.base64 || options.text) {
DatabaseLogger.log(`Starting to decrypt... hash: ${attachment.hash}`);
return await db.attachments.read(
attachment.hash,
options.base64 ? "base64" : "text"
);
}
let filename = getFileNameWithExtension(
attachment.filename,
attachment.mimeType
);
let key = await db.attachments.decryptKey(attachment.key);
let info = {
iv: attachment.iv,
salt: attachment.salt,
length: attachment.size,
alg: attachment.alg,
hash: attachment.hash,
hashType: attachment.hashType,
mime: attachment.mimeType,
fileName: options.cache ? undefined : filename,
uri: options.cache ? undefined : folder.uri,
chunkSize: attachment.chunkSize,
appGroupId: IOS_APPGROUPID
};
let fileUri = await Sodium.decryptFile(
key,
info,
options.cache ? "cache" : "file"
);
if (!options.silent) {
ToastManager.show({
heading: "Download successful",
message: filename + " downloaded",
type: "success"
});
}
if (
attachment.dateUploaded &&
!isImage(attachment.mimeType) &&
!isDocument(attachment.mimeType)
) {
RNFetchBlob.fs
.unlink(RNFetchBlob.fs.dirs.CacheDir + `/${attachment.hash}`)
.catch(console.log);
}
if (Platform.OS === "ios" && !options.cache) {
fileUri = folder.uri + `/${filename}`;
}
if (!options.silent) {
presentSheet({
title: "File downloaded",
paragraph: `${filename} saved to ${
Platform.OS === "android"
? "selected path"
: "File Manager/Notesnook/downloads"
}`,
icon: "download",
context: global ? null : attachment.hash,
component: <ShareComponent uri={fileUri} name={filename} padding={12} />
});
}
return fileUri;
} catch (e) {
DatabaseLogger.error(e);
if (attachment.dateUploaded) {
RNFetchBlob.fs
.unlink(RNFetchBlob.fs.dirs.CacheDir + `/${attachment.hash}`)
.catch(console.log);
RNFetchBlob.fs
.unlink(RNFetchBlob.fs.dirs.CacheDir + `/${attachment.hash}_dcache`)
.catch(console.log);
}
useAttachmentStore.getState().remove(attachment.hash);
if (options.throwError) {
throw e;
}
}
}

View File

@@ -1,353 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Sodium from "@ammarahmed/react-native-sodium";
import { getFileNameWithExtension } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import React from "react";
import { Platform } from "react-native";
import RNFetchBlob from "react-native-blob-util";
import * as ScopedStorage from "react-native-scoped-storage";
import { subscribe, zip } from "react-native-zip-archive";
import filesystem from ".";
import { ShareComponent } from "../../components/sheets/export-notes/share";
import { presentSheet, ToastManager } from "../../services/event-manager";
import { useAttachmentStore } from "../../stores/use-attachment-store";
import { IOS_APPGROUPID } from "../../utils/constants";
import { DatabaseLogger, db } from "../database";
import { createCacheDir, exists } from "./io";
import { cacheDir, copyFileAsync, releasePermissions } from "./utils";
export const FileDownloadStatus = {
Success: 1,
Fail: 0
};
/**
* Download all user's attachments
* @returns
*/
export async function downloadAllAttachments() {
const attachments = await db.attachments.all.ids();
return downloadAttachments(attachments);
}
/**
* Downloads provided attachments to a .zip file
* on user's device.
* @param {string[]} attachmentIds
* @param onProgress
* @returns
*/
export async function downloadAttachments(attachmentIds: string[]) {
await createCacheDir();
if (!attachmentIds || !attachmentIds.length) return;
const groupId = `download-all-${Date.now()}`;
let outputFolder;
if (Platform.OS === "android") {
// Ask the user to select a directory to store the file
const file = await ScopedStorage.openDocumentTree(true);
outputFolder = file.uri;
if (!outputFolder) return;
} else {
outputFolder = await filesystem.checkAndCreateDir("/downloads/");
}
// Create the folder to zip;
const zipSourceFolder = `${cacheDir}/notesnook-attachments`;
const zipOutputFile =
Platform.OS === "ios"
? `${outputFolder}/notesnook-attachments-${Date.now()}.zip`
: `${cacheDir}/notesnook-attachments.zip`;
if (await RNFetchBlob.fs.exists(zipSourceFolder)) {
await RNFetchBlob.fs.unlink(zipSourceFolder);
}
await RNFetchBlob.fs.mkdir(zipSourceFolder);
const isCancelled = () => {
if (useAttachmentStore.getState().downloading?.[groupId]?.canceled) {
RNFetchBlob.fs.unlink(zipSourceFolder).catch(() => {
/* empty */
});
useAttachmentStore.getState().setDownloading({
groupId,
current: 0,
total: 0,
success: false,
message: strings.network.dowloadCancelled(),
canceled: true
});
return true;
}
};
for (let i = 0; i < attachmentIds.length; i++) {
if (isCancelled()) return;
const attachment = await db.attachments.attachment(attachmentIds[i]);
if (!attachment) continue;
const hash = attachment.hash;
try {
useAttachmentStore.getState().setDownloading({
groupId: groupId,
current: i + 1,
total: attachmentIds.length,
filename: attachment.hash
});
// Download to cache
const uri = await downloadAttachment(hash, false, {
silent: true,
cache: true,
groupId: groupId
});
if (isCancelled()) return;
if (!uri) throw new Error(strings.failedToDownloadFile());
// Move file to the source folder we will zip eventually and rename the file to it's actual name.
const filePath = `${zipSourceFolder}/${attachment.filename}`;
await RNFetchBlob.fs.mv(`${cacheDir}/${uri}`, filePath);
} catch (e) {
DatabaseLogger.error(e);
}
}
useAttachmentStore.getState().setDownloading({
groupId: groupId,
current: 0,
total: 0,
success: true
});
if (isCancelled()) return;
let sub;
try {
useAttachmentStore.getState().setDownloading({
current: 0,
total: 1,
message: strings.savingZipFile(),
groupId
});
// If all goes well, zip the notesnook-attachments folder in cache.
sub = subscribe(({ progress }) => {
useAttachmentStore.getState().setDownloading({
groupId,
current: progress,
total: 1,
message: strings.savingZipFile((progress * 100).toFixed(1))
});
});
await zip(zipSourceFolder, zipOutputFile);
sub?.remove();
if (Platform.OS === "android") {
// Move the zip to user selected directory.
const file = await ScopedStorage.createFile(
outputFolder,
`notesnook-attachments-${Date.now()}.zip`,
"application/zip"
);
await copyFileAsync(`file://${zipOutputFile}`, file.uri);
}
useAttachmentStore.getState().setDownloading({
current: 0,
total: 0,
message: undefined,
success: true,
groupId
});
releasePermissions(outputFolder);
} catch (e) {
useAttachmentStore.getState().setDownloading({
current: 0,
total: 0,
message: undefined,
success: false,
groupId
});
releasePermissions(outputFolder);
sub?.remove();
ToastManager.error(e as Error, "Error zipping attachments");
}
// Remove source & zip file from cache.
RNFetchBlob.fs.unlink(zipSourceFolder).catch(() => {
/* empty */
});
if (Platform.OS === "android") {
RNFetchBlob.fs.unlink(zipOutputFile).catch(() => {
/* empty */
});
}
}
export default async function downloadAttachment(
hashOrId: string,
global = true,
options?: {
silent?: boolean;
cache?: boolean;
throwError?: boolean;
groupId?: string;
base64?: boolean;
text?: boolean;
}
) {
await createCacheDir();
const attachment = await db.attachments.attachment(hashOrId);
if (!attachment) {
DatabaseLogger.log("Attachment not found", {
hash: hashOrId
});
return;
}
let folder: {
uri: string;
} | null = null;
if (!options?.cache) {
if (Platform.OS === "android") {
folder = await ScopedStorage.openDocumentTree();
if (!folder) return;
} else {
folder = {
uri: await filesystem.checkAndCreateDir("/downloads/")
};
}
}
try {
useAttachmentStore.getState().setDownloading({
groupId: options?.groupId || attachment.hash,
current: 0,
total: 1,
filename: attachment.filename
});
await db
.fs()
.downloadFile(
options?.groupId || attachment.hash,
attachment.hash,
attachment.chunkSize
);
useAttachmentStore.getState().setDownloading({
groupId: options?.groupId || attachment.hash,
current: 1,
total: 1,
filename: attachment.filename,
success: true
});
if (!(await exists(attachment.hash))) {
DatabaseLogger.log("Attachment does not exist after download.");
return;
}
if (options?.base64 || options?.text) {
DatabaseLogger.log(`Starting to decrypt... hash: ${attachment.hash}`);
return await db.attachments.read(
attachment.hash,
options.base64 ? "base64" : "text"
);
}
const filename = await getFileNameWithExtension(
attachment.filename,
attachment.mimeType
);
const key = await db.attachments.decryptKey(attachment.key);
if (!key) return;
const info = {
iv: attachment.iv,
salt: attachment.salt,
length: attachment.size,
alg: attachment.alg,
hash: attachment.hash,
hashType: attachment.hashType,
mime: attachment.mimeType,
fileName: options?.cache ? undefined : filename,
uri: options?.cache ? undefined : folder?.uri,
chunkSize: attachment.chunkSize,
appGroupId: IOS_APPGROUPID
};
let fileUri = await Sodium.decryptFile(
key,
info,
options?.cache ? "cache" : "file"
);
if (!options?.silent) {
ToastManager.show({
heading: strings.network.downloadSuccess(),
message: strings.network.fileDownloaded(filename),
type: "success"
});
}
if (Platform.OS === "ios" && !options?.cache) {
fileUri = folder?.uri + `/${filename}`;
}
if (!options?.silent) {
presentSheet({
title: strings.network.fileDownloaded(),
paragraph: strings.fileSaved(filename, Platform.OS),
icon: "download",
context: global ? "global" : attachment.hash,
component: <ShareComponent uri={fileUri} name={filename} padding={12} />
});
}
return fileUri;
} catch (e) {
if (attachment.dateUploaded) {
RNFetchBlob.fs
.unlink(RNFetchBlob.fs.dirs.CacheDir + `/${attachment.hash}`)
.catch(() => {
/* empty */
});
RNFetchBlob.fs
.unlink(RNFetchBlob.fs.dirs.CacheDir + `/${attachment.hash}_dcache`)
.catch(() => {
/* empty */
});
}
useAttachmentStore.getState().setDownloading({
groupId: options?.groupId || attachment.hash,
current: 0,
total: 0,
filename: attachment.filename,
success: false
});
DatabaseLogger.error(e);
useAttachmentStore.getState().remove(attachment.hash);
if (options?.throwError) {
throw e;
}
}
}

View File

@@ -0,0 +1,153 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import hosts from "@notesnook/core/dist/utils/constants";
import NetInfo from "@react-native-community/netinfo";
import RNFetchBlob from "react-native-blob-util";
import { ToastManager } from "../../services/event-manager";
import { useAttachmentStore } from "../../stores/use-attachment-store";
import { DatabaseLogger, db } from "../database";
import { cacheDir, fileCheck } from "./utils";
import { createCacheDir, exists } from "./io";
export async function downloadFile(filename, data, cancelToken) {
if (!data) {
DatabaseLogger.log(`Error downloading file: ${filename}, reason: No data`);
return false;
}
DatabaseLogger.log(`Downloading ${filename}`);
await createCacheDir();
let { url, headers } = data;
let path = `${cacheDir}/${filename}`;
try {
if (await exists(filename)) {
DatabaseLogger.log(`File Exists already: ${filename}`);
return true;
}
let res = await fetch(url, {
method: "GET",
headers
});
if (!res.ok) {
DatabaseLogger.log(
`Error downloading file: ${filename}, ${res.status}, ${res.statusText}, reason: Unable to resolve download url`
);
throw new Error(`${res.status}: Unable to resolve download url`);
}
const downloadUrl = await res.text();
if (!downloadUrl) {
DatabaseLogger.log(
`Error downloading file: ${filename}, reason: Unable to resolve download url`
);
throw new Error("Unable to resolve download url");
}
let totalSize = 0;
DatabaseLogger.log(`Download starting: ${filename}`);
let request = RNFetchBlob.config({
path: path,
IOSBackgroundTask: true
})
.fetch("GET", downloadUrl, null)
.progress((recieved, total) => {
useAttachmentStore
.getState()
.setProgress(0, total, filename, recieved, "download");
totalSize = total;
DatabaseLogger.log(`Downloading: ${filename}, ${recieved}/${total}`);
});
cancelToken.cancel = () => {
useAttachmentStore.getState().remove(filename);
request.cancel();
DatabaseLogger.log(`Download cancelled: ${filename}`);
};
let response = await request;
await fileCheck(response, totalSize);
let status = response.info().status;
useAttachmentStore.getState().remove(filename);
return status >= 200 && status < 300;
} catch (e) {
if (e.message !== "canceled") {
ToastManager.show({
heading: "Error downloading file",
message: e.message,
type: "error",
context: "global"
});
ToastManager.show({
heading: "Error downloading file",
message: e.message,
type: "error",
context: "local"
});
}
useAttachmentStore.getState().remove(filename);
RNFetchBlob.fs.unlink(path).catch(console.log);
DatabaseLogger.error(e, {
url,
headers
});
return false;
}
}
export async function getUploadedFileSize(hash) {
try {
const url = `${hosts.API_HOST}/s3?name=${hash}`;
const token = await db.tokenManager.getAccessToken();
const attachmentInfo = await fetch(url, {
method: "HEAD",
headers: { Authorization: `Bearer ${token}` }
});
const contentLength = parseInt(
attachmentInfo.headers?.get("content-length")
);
return isNaN(contentLength) ? 0 : contentLength;
} catch (e) {
DatabaseLogger.error(e);
return -1;
}
}
export async function checkAttachment(hash) {
const internetState = await NetInfo.fetch();
const isInternetReachable =
internetState.isConnected && internetState.isInternetReachable;
if (!isInternetReachable) return { success: true };
const attachment = await db.attachments.attachment(hash);
if (!attachment) return { failed: "Attachment not found." };
try {
const size = await getUploadedFileSize(hash);
if (size === -1) return { success: true };
if (size === 0) return { failed: "File length is 0." };
} catch (e) {
return { failed: e?.message };
}
return { success: true };
}

View File

@@ -1,212 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { RequestOptions } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import NetInfo from "@react-native-community/netinfo";
import RNFetchBlob from "react-native-blob-util";
import { ToastManager } from "../../services/event-manager";
import { useAttachmentStore } from "../../stores/use-attachment-store";
import { DatabaseLogger, db } from "../database";
import { createCacheDir, exists } from "./io";
import { ABYTES, cacheDir, getUploadedFileSize, parseS3Error } from "./utils";
export async function downloadFile(
filename: string,
requestOptions: RequestOptions,
cancelToken: {
cancel: (reason?: string) => Promise<void>;
}
) {
if (!requestOptions) {
DatabaseLogger.log(
`Error downloading file: ${filename}, reason: No requestOptions`
);
return false;
}
DatabaseLogger.log(`Downloading ${filename}`);
await createCacheDir();
const { url, headers, chunkSize } = requestOptions;
const tempFilePath = `${cacheDir}/${filename}_temp`;
const originalFilePath = `${cacheDir}/${filename}`;
try {
if (await exists(filename)) {
DatabaseLogger.log(`File Exists already: ${filename}`);
return true;
}
const attachment = await db.attachments.attachment(filename);
if (!attachment) return false;
const size = await getUploadedFileSize(filename);
if (size === -1) {
const error = `${strings.fileVerificationFailed()} (File hash: ${filename})`;
throw new Error(error);
}
if (size === 0) {
const error = `${strings.fileLengthError()} (File hash: ${filename})`;
await db.attachments.markAsFailed(attachment.id, error);
throw new Error(error);
}
const totalChunks = Math.ceil(size / chunkSize);
const decryptedLength = size - totalChunks * ABYTES;
if (attachment && attachment.size !== decryptedLength) {
const error = `${strings.fileLengthMismatch(
attachment.size,
decryptedLength
)} (File hash: ${filename})`;
await db.attachments.markAsFailed(attachment.id, error);
throw new Error(error);
}
const resolveUrlResponse = await fetch(url, {
method: "GET",
headers
});
if (!resolveUrlResponse.ok) {
DatabaseLogger.log(
`Error downloading file: ${filename}, ${resolveUrlResponse.status}, ${resolveUrlResponse.statusText}, reason: Unable to resolve download url`
);
throw new Error(
`${resolveUrlResponse.status}: ${strings.failedToResolvedDownloadUrl()}`
);
}
const downloadUrl = await resolveUrlResponse.text();
if (!downloadUrl) {
DatabaseLogger.log(
`Error downloading file: ${filename}, reason: Unable to resolve download url`
);
throw new Error(strings.failedToResolvedDownloadUrl());
}
DatabaseLogger.log(`Download starting: ${filename}`);
const request = RNFetchBlob.config({
path: tempFilePath,
IOSBackgroundTask: true,
overwrite: true
})
.fetch("GET", downloadUrl)
.progress(async (recieved, total) => {
useAttachmentStore
.getState()
.setProgress(0, total, filename, recieved, "download");
DatabaseLogger.log(`Downloading: ${filename}, ${recieved}/${total}`);
});
cancelToken.cancel = async (reason) => {
useAttachmentStore.getState().remove(filename);
request.cancel();
RNFetchBlob.fs.unlink(tempFilePath).catch(() => {
/* empty */
});
DatabaseLogger.log(`Download cancelled: ${reason} ${filename}`);
};
const response = await request;
const contentType =
response.info().headers?.["content-type"] ||
response.info().headers?.["Content-Type"];
if (contentType === "application/xml") {
const error = parseS3Error(await response.text());
throw new Error(`[${error.Code}] ${error.Message}`);
}
const status = response.info().status;
useAttachmentStore.getState().remove(filename);
if (await exists(originalFilePath)) {
await RNFetchBlob.fs.unlink(originalFilePath).catch(() => {
/* empty */
});
}
await RNFetchBlob.fs.mv(tempFilePath, originalFilePath).catch(() => {
/* empty */
});
if (!(await exists(filename))) {
throw new Error("File size mismatch");
}
return status >= 200 && status < 300;
} catch (e) {
if (
(e as Error).message !== "canceled" &&
!(e as Error).message.includes("NoSuchKey")
) {
const toast = {
heading: strings.downloadError((e as Error).message),
message: (e as Error).message,
type: "error" as const,
context: "global"
};
ToastManager.show(toast);
toast.context = "local";
ToastManager.show(toast);
}
useAttachmentStore.getState().remove(filename);
RNFetchBlob.fs.unlink(tempFilePath).catch(() => {
/* empty */
});
RNFetchBlob.fs.unlink(originalFilePath).catch(() => {
/* empty */
});
DatabaseLogger.error(e, "Download failed: ", {
url
});
return false;
}
}
export async function checkAttachment(hash: string) {
const internetState = await NetInfo.fetch();
const isInternetReachable =
internetState.isConnected && internetState.isInternetReachable;
if (!isInternetReachable) return;
const attachment = await db.attachments.attachment(hash);
if (!attachment) return { failed: "Attachment not found." };
try {
const size = await getUploadedFileSize(hash);
if (size === -1) return { success: true };
if (size === 0)
return {
failed: `File length is 0. Please upload this file again from the attachment manager. (File hash: ${hash})`
};
} catch (e) {
return { failed: (e as Error)?.message };
}
return { success: true };
}

View File

@@ -17,41 +17,19 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { IFileStorage } from "@notesnook/core";
import { checkAttachment, downloadFile } from "./download";
import { checkAttachment, downloadFile, getUploadedFileSize } from "./download";
import {
bulkExists,
clearCache,
clearFileStorage,
deleteCacheFileByName,
deleteCacheFileByPath,
deleteFile,
exists,
getCacheSize,
hashBase64,
readEncrypted,
writeEncryptedBase64
writeEncryptedBase64,
hashBase64
} from "./io";
import { uploadFile } from "./upload";
import {
cancelable,
checkAndCreateDir,
getUploadedFileSize,
requestPermission
} from "./utils";
import { cancelable } from "./utils";
export default {
checkAttachment,
clearCache,
deleteCacheFileByName,
deleteCacheFileByPath,
getCacheSize,
requestPermission,
checkAndCreateDir,
getUploadedFileSize
};
export const FileStorage: IFileStorage = {
readEncrypted,
writeEncryptedBase64,
hashBase64,
@@ -61,5 +39,5 @@ export const FileStorage: IFileStorage = {
exists,
clearFileStorage,
getUploadedFileSize,
bulkExists
checkAttachment
};

View File

@@ -0,0 +1,202 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Sodium from "@ammarahmed/react-native-sodium";
import { Platform } from "react-native";
import RNFetchBlob from "react-native-blob-util";
import { IOS_APPGROUPID } from "../../utils/constants";
import { DatabaseLogger, db } from "../database";
import { cacheDir, cacheDirOld, getRandomId } from "./utils";
export async function readEncrypted(filename, key, cipherData) {
await migrateFilesFromCache();
DatabaseLogger.log("Read encrypted file...");
let path = `${cacheDir}/${filename}`;
try {
if (!(await exists(filename))) {
return false;
}
let output = await Sodium.decryptFile(
key,
{
...cipherData,
hash: filename,
appGroupId: IOS_APPGROUPID
},
cipherData.outputType === "base64" ? "base64" : "text"
);
DatabaseLogger.log("File decrypted...");
return output;
} catch (e) {
RNFetchBlob.fs.unlink(path).catch(console.log);
DatabaseLogger.error(e);
return false;
}
}
export async function hashBase64(data) {
const hash = await Sodium.hashFile({
type: "base64",
data,
uri: ""
});
return {
hash: hash,
type: "xxh64"
};
}
export async function writeEncryptedBase64(data, key) {
await createCacheDir();
let filepath = cacheDir + `/${getRandomId("imagecache_")}`;
await RNFetchBlob.fs.writeFile(filepath, data, "base64");
let output = await Sodium.encryptFile(key, {
uri: Platform.OS === "ios" ? filepath : "file://" + filepath,
type: "url"
});
RNFetchBlob.fs.unlink(filepath).catch(console.log);
console.log("encrypted file output: ", output);
output.size = output.length;
delete output.length;
return {
...output,
alg: "xcha-stream"
};
}
export async function deleteFile(filename, data) {
await createCacheDir();
let delFilePath = cacheDir + `/${filename}`;
if (!data) {
if (!filename) return;
RNFetchBlob.fs.unlink(delFilePath).catch(console.log);
return true;
}
let { url, headers } = data;
try {
let response = await RNFetchBlob.fetch("DELETE", url, headers);
let status = response.info().status;
let ok = status >= 200 && status < 300;
if (ok) {
RNFetchBlob.fs.unlink(delFilePath).catch(console.log);
}
return ok;
} catch (e) {
console.log("delete file: ", e, url, headers);
return false;
}
}
export async function clearFileStorage() {
try {
let files = await RNFetchBlob.fs.ls(cacheDir);
let oldCache = await RNFetchBlob.fs.ls(cacheDirOld);
for (let file of files) {
await RNFetchBlob.fs.unlink(cacheDir + `/${file}`).catch(console.log);
}
for (let file of oldCache) {
await RNFetchBlob.fs.unlink(cacheDirOld + `/${file}`).catch(console.log);
}
} catch (e) {
console.log("clearFileStorage", e);
}
}
export async function createCacheDir() {
if (!(await RNFetchBlob.fs.exists(cacheDir))) {
await RNFetchBlob.fs.mkdir(cacheDir);
DatabaseLogger.log("Cache directory created");
}
}
export async function migrateFilesFromCache() {
try {
await createCacheDir();
const migratedFilesPath = cacheDir + "/.migrated_1";
const migrated = await RNFetchBlob.fs.exists(migratedFilesPath);
if (migrated) {
console.log("Files migrated already");
return;
}
let files = await RNFetchBlob.fs.ls(cacheDir);
console.log("Files to migrate:", files.join(","));
let oldCache = await RNFetchBlob.fs.ls(cacheDirOld);
for (let file of oldCache) {
if (file.startsWith("org.") || file.startsWith("com.")) continue;
RNFetchBlob.fs
.mv(cacheDirOld + `/${file}`, cacheDir + `/${file}`)
.catch(console.log);
console.log("Moved", file);
}
await RNFetchBlob.fs.createFile(migratedFilesPath, "1", "utf8");
} catch (e) {
console.log("migrateFilesFromCache", e);
}
}
const ABYTES = 17;
export async function exists(filename) {
let path = `${cacheDir}/${filename}`;
const iosAppGroup =
Platform.OS === "ios"
? await RNFetchBlob.fs.pathForAppGroup(IOS_APPGROUPID)
: null;
const appGroupPath = `${iosAppGroup}/${filename}`;
let exists = await RNFetchBlob.fs.exists(path);
// Check if file is present in app group path.
let existsInAppGroup = false;
if (!exists && Platform.OS === "ios") {
existsInAppGroup = await RNFetchBlob.fs.exists(appGroupPath);
}
if (exists || existsInAppGroup) {
const attachment = await db.attachments.attachment(filename);
const totalChunks = Math.ceil(attachment.size / attachment.chunkSize);
const totalAbytes = totalChunks * ABYTES;
const expectedFileSize = attachment.size + totalAbytes;
const stat = await RNFetchBlob.fs.stat(
existsInAppGroup ? appGroupPath : path
);
if (stat.size !== expectedFileSize) {
DatabaseLogger.log(
`File size mismatch: ${filename}, expected: ${expectedFileSize}, actual: ${stat.size}`
);
RNFetchBlob.fs
.unlink(existsInAppGroup ? appGroupPath : path)
.catch(console.log);
return false;
}
exists = true;
}
return exists;
}

View File

@@ -1,331 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Sodium from "@ammarahmed/react-native-sodium";
import {
FileEncryptionMetadataWithHash,
FileEncryptionMetadataWithOutputType,
Output,
RequestOptions
} from "@notesnook/core";
import { DataFormat, SerializedKey } from "@notesnook/crypto";
import { Platform } from "react-native";
import RNFetchBlob from "react-native-blob-util";
import { eSendEvent } from "../../services/event-manager";
import { IOS_APPGROUPID } from "../../utils/constants";
import { DatabaseLogger, db } from "../database";
import { ABYTES, cacheDir, cacheDirOld, getRandomId } from "./utils";
export async function readEncrypted<TOutputFormat extends DataFormat>(
filename: string,
key: SerializedKey,
cipherData: FileEncryptionMetadataWithOutputType<TOutputFormat>
) {
await migrateFilesFromCache();
DatabaseLogger.log("Read encrypted file...");
const path = `${cacheDir}/${filename}`;
try {
if (!(await exists(filename))) {
return;
}
const output = await Sodium.decryptFile(
key,
{
...cipherData,
hash: filename,
appGroupId: IOS_APPGROUPID
},
cipherData.outputType === "base64" ? "base64" : "text"
);
DatabaseLogger.log("File decrypted...");
return output as Output<TOutputFormat>;
} catch (e) {
RNFetchBlob.fs.unlink(path).catch(() => {
/* empty */
});
DatabaseLogger.error(e);
}
}
export async function hashBase64(data: string) {
const hash = await Sodium.hashFile({
type: "base64",
data,
uri: ""
});
return {
hash: hash,
type: "xxh64"
};
}
export async function writeEncryptedBase64(
data: string,
encryptionKey: SerializedKey,
mimeType: string
): Promise<FileEncryptionMetadataWithHash> {
await createCacheDir();
const filepath = cacheDir + `/${getRandomId("imagecache_")}`;
await RNFetchBlob.fs.writeFile(filepath, data, "base64");
const output = await Sodium.encryptFile(encryptionKey, {
uri: Platform.OS === "ios" ? filepath : "file://" + filepath,
type: "url"
});
RNFetchBlob.fs.unlink(filepath).catch(() => {
/* empty */
});
return {
...output,
alg: "xcha-stream"
};
}
export async function deleteFile(
filename: string,
requestOptions?: RequestOptions
): Promise<boolean> {
await createCacheDir();
const localFilePath = cacheDir + `/${filename}`;
if (!requestOptions) {
RNFetchBlob.fs.unlink(localFilePath).catch(() => {
/* empty */
});
return true;
}
const { url, headers } = requestOptions;
try {
const response = await RNFetchBlob.fetch("DELETE", url, headers);
const status = response.info().status;
const ok = status >= 200 && status < 300;
if (ok) {
RNFetchBlob.fs.unlink(localFilePath).catch(() => {
/* empty */
});
}
return ok;
} catch (e) {
DatabaseLogger.error(e, "Delete file", {
url: url
});
return false;
}
}
export async function clearFileStorage() {
try {
const files = await RNFetchBlob.fs.ls(cacheDir);
const oldCache = await RNFetchBlob.fs.ls(cacheDirOld);
for (const file of files) {
await RNFetchBlob.fs.unlink(cacheDir + `/${file}`).catch(() => {
/* empty */
});
}
for (const file of oldCache) {
await RNFetchBlob.fs.unlink(cacheDirOld + `/${file}`).catch(() => {
/* empty */
});
}
} catch (e) {
DatabaseLogger.error(e, "clearFileStorage");
}
}
export async function createCacheDir() {
try {
if (!(await RNFetchBlob.fs.exists(cacheDir))) {
await RNFetchBlob.fs.mkdir(cacheDir);
DatabaseLogger.log("Cache directory created");
}
} catch (e) {
DatabaseLogger.error(e);
}
}
export async function migrateFilesFromCache() {
try {
await createCacheDir();
const migratedFilesPath = cacheDir + "/.migrated_1";
const migrated = await RNFetchBlob.fs.exists(migratedFilesPath);
if (migrated) {
return;
}
const files = await RNFetchBlob.fs.ls(cacheDir);
const oldCache = await RNFetchBlob.fs.ls(cacheDirOld);
for (const file of oldCache) {
if (file.startsWith("org.") || file.startsWith("com.")) continue;
RNFetchBlob.fs
.mv(cacheDirOld + `/${file}`, cacheDir + `/${file}`)
.catch(() => {
/* empty */
});
}
await RNFetchBlob.fs.createFile(migratedFilesPath, "1", "utf8");
} catch (e) {
DatabaseLogger.error(e, "migrateFilesFromCache");
}
}
export async function clearCache() {
await RNFetchBlob.fs.unlink(cacheDir).catch(() => {
/* empty */
});
await createCacheDir();
eSendEvent("cache-cleared");
}
export async function deleteCacheFileByPath(path: string) {
await RNFetchBlob.fs.unlink(path).catch(() => {
/* empty */
});
}
export async function deleteCacheFileByName(name: string) {
const iosAppGroup =
Platform.OS === "ios"
? await (RNFetchBlob.fs as any).pathForAppGroup(IOS_APPGROUPID)
: null;
const appGroupPath = `${iosAppGroup}/${name}`;
await RNFetchBlob.fs.unlink(appGroupPath).catch(() => {
/* empty */
});
await RNFetchBlob.fs.unlink(`${cacheDir}/${name}`).catch(() => {
/* empty */
});
}
export async function deleteDCacheFiles() {
const files = await RNFetchBlob.fs.ls(cacheDir);
for (const file of files) {
if (file.includes("_dcache") || file.startsWith("NN_")) {
await RNFetchBlob.fs.unlink(file).catch(() => {
/* empty */
});
}
}
}
export async function getCachePathForFile(filename: string) {
const path = `${cacheDir}/${filename}`;
const iosAppGroup =
Platform.OS === "ios"
? await (RNFetchBlob.fs as any).pathForAppGroup(IOS_APPGROUPID)
: null;
const appGroupPath = `${iosAppGroup}/${filename}`;
const exists = await RNFetchBlob.fs.exists(path);
// Check if file is present in app group path.
let existsInAppGroup = false;
if (!exists && Platform.OS === "ios") {
existsInAppGroup = await RNFetchBlob.fs.exists(appGroupPath);
}
return existsInAppGroup ? appGroupPath : path;
}
export async function exists(filename: string) {
const path = `${cacheDir}/${filename}`;
const iosAppGroup =
Platform.OS === "ios"
? await (RNFetchBlob.fs as any).pathForAppGroup(IOS_APPGROUPID)
: null;
const appGroupPath = `${iosAppGroup}/${filename}`;
let exists = await RNFetchBlob.fs.exists(path);
// Check if file is present in app group path.
let existsInAppGroup = false;
if (!exists && Platform.OS === "ios") {
existsInAppGroup = await RNFetchBlob.fs.exists(appGroupPath);
}
if (exists || existsInAppGroup) {
const attachment = await db.attachments.attachment(filename);
if (!attachment) return false;
const totalChunks = Math.ceil(attachment.size / attachment.chunkSize);
const totalAbytes = totalChunks * ABYTES;
const expectedFileSize = attachment.size + totalAbytes;
const stat = await RNFetchBlob.fs.stat(
existsInAppGroup ? appGroupPath : path
);
if (stat.size !== expectedFileSize) {
DatabaseLogger.log(
`File size mismatch: ${filename}, expected: ${expectedFileSize}, actual: ${stat.size}`
);
RNFetchBlob.fs
.unlink(existsInAppGroup ? appGroupPath : path)
.catch(() => {
/* empty */
});
return false;
}
exists = true;
}
return exists;
}
export async function bulkExists(files: string[]) {
try {
await createCacheDir();
const cacheFiles = await RNFetchBlob.fs.ls(cacheDir);
let missingFiles = files.filter((file) => !cacheFiles.includes(file));
if (Platform.OS === "ios") {
const iosAppGroup =
Platform.OS === "ios"
? await (RNFetchBlob.fs as any).pathForAppGroup(IOS_APPGROUPID)
: null;
const appGroupFiles = await RNFetchBlob.fs.ls(iosAppGroup);
missingFiles = missingFiles.filter(
(file) => !appGroupFiles.includes(file)
);
}
return missingFiles;
} catch (e) {
DatabaseLogger.error(e);
return [];
}
}
export async function getCacheSize() {
await createCacheDir();
const stat = await RNFetchBlob.fs.lstat(`file://` + cacheDir);
let total = 0;
stat.forEach((file) => {
total += parseInt(file.size as unknown as string);
});
return total;
}

View File

@@ -17,71 +17,42 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { RequestOptions } from "@notesnook/core";
import { Platform } from "react-native";
import RNFetchBlob from "react-native-blob-util";
import { ToastManager } from "../../services/event-manager";
import { useAttachmentStore } from "../../stores/use-attachment-store";
import { IOS_APPGROUPID } from "../../utils/constants";
import { DatabaseLogger, db } from "../database";
import { cacheDir } from "./utils";
import { isImage, isDocument } from "@notesnook/core/dist/utils/filename";
import { Platform } from "react-native";
import { IOS_APPGROUPID } from "../../utils/constants";
import { createCacheDir } from "./io";
import {
cacheDir,
checkUpload,
FileSizeResult,
getUploadedFileSize
} from "./utils";
import { getUploadedFileSize } from "./download";
import { ToastManager } from "../../services/event-manager";
export async function uploadFile(
filename: string,
requestOptions: RequestOptions,
cancelToken: {
cancel: (reason?: string) => Promise<void>;
}
) {
if (!requestOptions) return false;
const { url, headers } = requestOptions;
export async function uploadFile(filename, data, cancelToken) {
if (!data) return false;
let { url, headers } = data;
await createCacheDir();
DatabaseLogger.info(`Preparing to upload file: ${filename}`);
try {
let filePath = `${cacheDir}/${filename}`;
let exists = await RNFetchBlob.fs.exists(filePath);
// Check for file in appGroupPath if it doesn't exist in cacheDir
if (!exists && Platform.OS === "ios") {
const iosAppGroup =
Platform.OS === "ios"
? await (RNFetchBlob.fs as any).pathForAppGroup(IOS_APPGROUPID)
: null;
const appGroupPath = `${iosAppGroup}/${filename}`;
filePath = appGroupPath;
exists = await RNFetchBlob.fs.exists(filePath);
const uploadedFileSize = await getUploadedFileSize(filename);
if (uploadedFileSize === -1) {
DatabaseLogger.log("Upload verification failed.");
return false;
}
if (!exists) {
throw new Error(
`Trying to upload file at path ${filePath} that doest not exist.`
);
}
const fileSize = (await RNFetchBlob.fs.stat(filePath)).size;
const remoteFileSize = await getUploadedFileSize(filename);
if (remoteFileSize === FileSizeResult.Error) return false;
if (remoteFileSize > FileSizeResult.Empty && remoteFileSize === fileSize) {
const isUploaded = uploadedFileSize !== 0;
if (isUploaded) {
DatabaseLogger.log(`File ${filename} is already uploaded.`);
return true;
}
const uploadUrlResponse = await fetch(url, {
let res = await fetch(url, {
method: "PUT",
headers
});
const uploadUrl = uploadUrlResponse.ok
? await uploadUrlResponse.text()
: await uploadUrlResponse.json();
const uploadUrl = res.ok ? await res.text() : await res.json();
if (typeof uploadUrl !== "string") {
throw new Error(
@@ -89,10 +60,28 @@ export async function uploadFile(
);
}
let uploadFilePath = `${cacheDir}/${filename}`;
const iosAppGroup =
Platform.OS === "ios"
? await RNFetchBlob.fs.pathForAppGroup(IOS_APPGROUPID)
: null;
const appGroupPath = `${iosAppGroup}/${filename}`;
let exists = await RNFetchBlob.fs.exists(uploadFilePath);
if (!exists && Platform.OS === "ios") {
uploadFilePath = appGroupPath;
exists = await RNFetchBlob.fs.exists(uploadFilePath);
}
if (!exists) {
throw new Error(
`Trying to upload file at path ${uploadFilePath} that doest not exist.`
);
}
DatabaseLogger.info(`Starting upload: ${filename}`);
const uploadRequest = RNFetchBlob.config({
//@ts-ignore
let request = RNFetchBlob.config({
IOSBackgroundTask: !globalThis["IS_SHARE_EXTENSION"]
})
.fetch(
@@ -101,7 +90,7 @@ export async function uploadFile(
{
"content-type": ""
},
RNFetchBlob.wrap(filePath)
RNFetchBlob.wrap(uploadFilePath)
)
.uploadProgress((sent, total) => {
useAttachmentStore
@@ -112,33 +101,38 @@ export async function uploadFile(
);
});
cancelToken.cancel = async () => {
cancelToken.cancel = () => {
useAttachmentStore.getState().remove(filename);
uploadRequest.cancel();
request.cancel();
};
let response = await request;
const uploadResponse = await uploadRequest;
const status = uploadResponse.info().status;
const uploaded = status >= 200 && status < 300;
let status = response.info().status;
let text = await response.text();
let result = status >= 200 && status < 300 && text.length === 0;
useAttachmentStore.getState().remove(filename);
if (!uploaded) {
const fileInfo = await RNFetchBlob.fs.stat(filePath);
if (result) {
DatabaseLogger.info(
`File upload status: ${filename}, ${status}, ${text}`
);
let attachment = await db.attachments.attachment(filename);
if (!attachment) return result;
if (!isImage(attachment.mimeType) && !isDocument(attachment.mimeType)) {
RNFetchBlob.fs.unlink(`${cacheDir}/${filename}`).catch(console.log);
}
} else {
const fileInfo = await RNFetchBlob.fs.stat(uploadFilePath);
throw new Error(
`${status}, name: ${fileInfo.filename}, length: ${
`${status}, ${text}, name: ${fileInfo.filename}, length: ${
fileInfo.size
}, info: ${JSON.stringify(uploadResponse.info())}`
}, info: ${JSON.stringify(response.info())}`
);
}
const attachment = await db.attachments.attachment(filename);
if (!attachment) return false;
await checkUpload(filename, requestOptions.chunkSize, attachment.size);
DatabaseLogger.info(`File upload status: ${filename}, ${status}`);
return uploaded;
return result;
} catch (e) {
useAttachmentStore.getState().remove(filename);
ToastManager.error(e as Error, "File upload failed");
ToastManager.error(e, "File upload failed");
DatabaseLogger.error(e, "File upload failed", {
filename
});

View File

@@ -0,0 +1,93 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import * as ScopedStorage from "react-native-scoped-storage";
import { Platform } from "react-native";
import RNFetchBlob from "react-native-blob-util";
export const cacheDirOld = RNFetchBlob.fs.dirs.CacheDir;
export const cacheDir =
Platform.OS == "ios"
? RNFetchBlob.fs.dirs.LibraryDir + "/.cache"
: RNFetchBlob.fs.dirs.DocumentDir + "/.cache";
export function getRandomId(prefix) {
return Math.random()
.toString(36)
.replace("0.", prefix || "");
}
export function extractValueFromXmlTag(code, xml) {
if (!xml.includes(code)) return `Unknown ${code}`;
return xml.slice(
xml.indexOf(`<${code}>`) + code.length + 2,
xml.indexOf(`</${code}>`)
);
}
export async function fileCheck(response, totalSize) {
if (totalSize < 1000) {
let text = await response.text();
if (text.startsWith("<?xml")) {
let errorJson = {
Code: extractValueFromXmlTag("Code", text),
Message: extractValueFromXmlTag("Message", text)
};
throw new Error(`${errorJson.Code}: ${errorJson.Message}`);
}
}
}
export function cancelable(operation) {
const cancelToken = {
cancel: () => {}
};
return (filename, { url, headers }) => {
return {
execute: () => operation(filename, { url, headers }, cancelToken),
cancel: async () => {
await cancelToken.cancel();
RNFetchBlob.fs.unlink(`${cacheDir}/${filename}`).catch(console.log);
}
};
};
}
export function copyFileAsync(source, dest) {
return new Promise((resolve, reject) => {
ScopedStorage.copyFile(source, dest, (e, r) => {
if (e) {
reject(e);
return;
}
resolve();
});
});
}
export async function releasePermissions(path) {
if (Platform.OS === "ios") return;
const uris = await ScopedStorage.getPersistedUriPermissions();
for (let uri of uris) {
if (path.startsWith(uri)) {
await ScopedStorage.releasePersistableUriPermission(uri);
}
}
}

View File

@@ -1,191 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { hosts, RequestOptions } from "@notesnook/core";
import { Platform } from "react-native";
import RNFetchBlob from "react-native-blob-util";
import * as ScopedStorage from "react-native-scoped-storage";
import { DatabaseLogger, db } from "../database";
export const ABYTES = 17;
export const cacheDirOld = RNFetchBlob.fs.dirs.CacheDir;
export const cacheDir =
Platform.OS == "ios"
? RNFetchBlob.fs.dirs.LibraryDir + "/.cache"
: RNFetchBlob.fs.dirs.DocumentDir + "/.cache";
export function getRandomId(prefix: string) {
return Math.random()
.toString(36)
.replace("0.", prefix || "");
}
export function parseS3Error(data?: string) {
const xml = typeof data === "string" ? data : null;
const error = {
Code: "UNKNOWN",
Message: xml
};
try {
if (!xml) return error;
const doc = new DOMParser().parseFromString(xml, "text/xml");
const ErrorElement = doc.getElementsByTagName("Error")[0];
if (!ErrorElement) return error;
for (const child of ErrorElement.children) {
if (
child.textContent &&
(child.tagName === "Code" || child.tagName === "Message")
)
error[child.tagName] = child.textContent;
}
return error;
} catch (e) {
return error;
}
}
export function cancelable(
operation: (
filename: string,
requestOptions: RequestOptions,
cancelToken: {
cancel: (reason?: string) => Promise<void>;
}
) => Promise<boolean>
) {
const cancelToken = {
cancel: async (reason?: string) => {}
};
return (filename: string, requestOptions: RequestOptions) => {
return {
execute: () => operation(filename, requestOptions, cancelToken),
cancel: async () => {
await cancelToken.cancel();
}
};
};
}
export function copyFileAsync(source: string, dest: string) {
return new Promise((resolve, reject) => {
//@ts-ignore
ScopedStorage.copyFile(source, dest, (e: any, r: any) => {
if (e) {
reject(e);
return;
}
resolve(true);
});
});
}
export async function releasePermissions(path: string) {
if (Platform.OS === "ios") return;
const uris = await ScopedStorage.getPersistedUriPermissions();
for (const uri of uris) {
if (path.startsWith(uri)) {
await ScopedStorage.releasePersistableUriPermission(uri);
}
}
}
export const FileSizeResult = {
Empty: 0,
Error: -1
};
export async function getUploadedFileSize(hash: string, retry = 0) {
try {
const url = `${hosts.API_HOST}/s3?name=${hash}`;
const token = await db.tokenManager.getAccessToken();
const attachmentInfo = await fetch(url, {
method: "HEAD",
headers: { Authorization: `Bearer ${token}` }
});
if (
!attachmentInfo.ok ||
attachmentInfo.headers?.get("content-length") === null
) {
if (retry < 3) {
DatabaseLogger.log(`Retrying file size check: ${hash}, ${retry}`);
return getUploadedFileSize(hash, retry + 1);
}
throw new Error(
`File size check failed: ${hash}, ${
attachmentInfo.status
}, ${attachmentInfo.headers?.get("content-length")}`
);
}
const contentLength = parseInt(
attachmentInfo.headers?.get("content-length") as string
);
return isNaN(contentLength) ? FileSizeResult.Empty : contentLength;
} catch (e) {
DatabaseLogger.error(e);
return FileSizeResult.Error;
}
}
export async function checkUpload(
filename: string,
chunkSize: number,
expectedSize: number
) {
const size = await getUploadedFileSize(filename);
const totalChunks = Math.ceil(size / chunkSize);
const decryptedLength = size - totalChunks * ABYTES;
const error =
size === 0
? `File size is 0.`
: size === -1
? `File verification check failed.`
: expectedSize !== decryptedLength
? `File size mismatch. Expected ${size} bytes but got ${decryptedLength} bytes.`
: undefined;
if (error) throw new Error(error);
}
export async function requestPermission() {
if (Platform.OS === "ios") return true;
return true;
}
export async function checkAndCreateDir(path: string) {
const dir =
Platform.OS === "ios"
? RNFetchBlob.fs.dirs.DocumentDir + path
: RNFetchBlob.fs.dirs.SDCardDir + "/Notesnook/" + path;
try {
const exists = await RNFetchBlob.fs.exists(dir);
const isDir = await RNFetchBlob.fs.isDir(dir);
if (!exists || !isDir) {
await RNFetchBlob.fs.mkdir(dir);
}
} catch (e) {
await RNFetchBlob.fs.mkdir(dir);
}
return dir;
}

View File

@@ -17,7 +17,9 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
function info(context: string, ...logs: unknown[]) {}
function info(context: string, ...logs: unknown[]) {
console.log(`${new Date().toLocaleDateString()}::info::${context}:`, ...logs);
}
function error(context: string, ...logs: unknown[]) {
console.log(

View File

@@ -24,7 +24,6 @@ import { useMessageStore } from "../../stores/use-message-store";
import { useSelectionStore } from "../../stores/use-selection-store";
import { allowedOnPlatform, renderItem } from "./functions";
import { getContainerBorder } from "../../utils/colors";
import { DefaultAppStyles } from "../../utils/styles";
export const Announcement = ({ color }) => {
const { colors } = useThemeColors();
@@ -37,8 +36,9 @@ export const Announcement = ({ color }) => {
style={{
backgroundColor: colors.primary.background,
width: "100%",
paddingHorizontal: DefaultAppStyles.GAP,
paddingVertical: DefaultAppStyles.GAP_VERTICAL
paddingHorizontal: 12,
paddingTop: 12,
paddingBottom: 12
}}
>
<View
@@ -54,7 +54,7 @@ export const Announcement = ({ color }) => {
<View
style={{
width: "100%",
marginTop: DefaultAppStyles.GAP_VERTICAL
marginTop: 12
}}
>
{announcement?.body

View File

@@ -20,13 +20,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import React from "react";
import Paragraph from "../ui/typography/paragraph";
import { getStyle } from "./functions";
import { DefaultAppStyles } from "../../utils/styles";
export const Body = ({ text, style = {} }) => {
return (
<Paragraph
style={{
paddingHorizontal: DefaultAppStyles.GAP,
paddingHorizontal: 12,
...getStyle(style)
}}
>

View File

@@ -23,13 +23,12 @@ import { Linking, View } from "react-native";
import { useThemeColors } from "@notesnook/theme";
import { eSendEvent, presentSheet } from "../../services/event-manager";
import { eCloseAnnouncementDialog } from "../../utils/events";
import { AppFontSize } from "../../utils/size";
import { SIZE } from "../../utils/size";
import { sleep } from "../../utils/time";
import { PricingPlans } from "../premium/pricing-plans";
import SheetProvider from "../sheet-provider";
import { Button } from "../ui/button";
import { allowedOnPlatform, getStyle } from "./functions";
import { DefaultAppStyles } from "../../utils/styles";
export const Cta = ({ actions, style = {}, color, inline }) => {
const { colors } = useThemeColors();
@@ -42,9 +41,7 @@ export const Cta = ({ actions, style = {}, color, inline }) => {
await sleep(500);
}
if (item.type === "link") {
Linking.openURL(item.data).catch(() => {
/* empty */
});
Linking.openURL(item.data).catch(console.log);
} else if (item.type === "promo") {
presentSheet({
component: (
@@ -62,7 +59,7 @@ export const Cta = ({ actions, style = {}, color, inline }) => {
return (
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP,
paddingHorizontal: 12,
...getStyle(style),
flexDirection: inline ? "row" : "column"
}}
@@ -76,7 +73,7 @@ export const Cta = ({ actions, style = {}, color, inline }) => {
<Button
key={item.title}
title={item.title}
fontSize={AppFontSize.sm}
fontSize={SIZE.sm}
type="transparent"
textStyle={{
textDecorationLine: "underline"
@@ -84,7 +81,7 @@ export const Cta = ({ actions, style = {}, color, inline }) => {
onPress={() => onPress(item)}
bold
style={{
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
height: 30,
alignSelf: "flex-start",
paddingHorizontal: 0
}}
@@ -96,7 +93,7 @@ export const Cta = ({ actions, style = {}, color, inline }) => {
<Button
key={item.title}
title={item.title}
fontSize={AppFontSize.sm}
fontSize={SIZE.sm}
type="plain"
onPress={() => onPress(item)}
width={null}
@@ -119,7 +116,7 @@ export const Cta = ({ actions, style = {}, color, inline }) => {
<Button
key={item.title}
title={item.title}
fontSize={AppFontSize.md}
fontSize={SIZE.md}
buttonType={{
color: color ? color : colors.primary.accent,
text: color
@@ -131,7 +128,7 @@ export const Cta = ({ actions, style = {}, color, inline }) => {
onPress={() => onPress(item)}
width={250}
style={{
marginBottom: DefaultAppStyles.GAP_VERTICAL,
marginBottom: 5,
borderRadius: 100
}}
/>
@@ -142,14 +139,14 @@ export const Cta = ({ actions, style = {}, color, inline }) => {
<Button
key={item.title}
title={item.title}
fontSize={AppFontSize.xs}
fontSize={SIZE.xs}
type="plain"
onPress={() => onPress(item)}
width={null}
height={30}
style={{
minWidth: "50%",
marginTop: DefaultAppStyles.GAP_VERTICAL_SMALL
marginTop: 5
}}
textStyle={{
textDecorationLine: "underline"

View File

@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import { AppFontSize } from "../../utils/size";
import { SIZE } from "../../utils/size";
import Paragraph from "../ui/typography/paragraph";
import { getStyle } from "./functions";
@@ -30,7 +30,7 @@ export const Description = ({ text, style = {}, inline }) => {
...getStyle(style),
textAlign: inline ? "left" : style?.textAlign
}}
size={inline ? AppFontSize.sm : AppFontSize.md}
size={inline ? SIZE.sm : SIZE.md}
>
{text}
</Paragraph>

View File

@@ -28,7 +28,6 @@ import { List } from "./list";
import { Photo } from "./photo";
import { SubHeading } from "./subheading";
import { Title } from "./title";
import { DefaultAppStyles } from "../../utils/styles";
export function allowedOnPlatform(platforms) {
if (!platforms) return true;
@@ -54,7 +53,7 @@ const Features = () => {
return (
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP,
paddingHorizontal: 12,
alignItems: "center",
width: "100%"
}}

View File

@@ -22,13 +22,12 @@ import { View } from "react-native";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import Paragraph from "../ui/typography/paragraph";
import { getStyle } from "./functions";
import { DefaultAppStyles } from "../../utils/styles";
export const List = ({ items, listType, style = {} }) => {
return (
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP,
paddingHorizontal: 12,
paddingLeft: listType === "ordered" ? 25 : 25,
...getStyle(style)
}}
@@ -37,7 +36,7 @@ export const List = ({ items, listType, style = {} }) => {
<View
key={item.text}
style={{
paddingVertical: DefaultAppStyles.GAP_VERTICAL,
paddingVertical: 6,
flexDirection: "row"
}}
>

View File

@@ -18,18 +18,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import { AppFontSize } from "../../utils/size";
import { SIZE } from "../../utils/size";
import Heading from "../ui/typography/heading";
import { getStyle } from "./functions";
import { DefaultAppStyles } from "../../utils/styles";
export const SubHeading = ({ text, style = {} }) => {
return (
<Heading
size={AppFontSize.md + 2}
size={SIZE.md + 2}
style={{
marginHorizontal: DefaultAppStyles.GAP,
marginTop: DefaultAppStyles.GAP_VERTICAL,
marginHorizontal: 12,
marginTop: 12,
...getStyle(style)
}}
>

View File

@@ -20,11 +20,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import React from "react";
import { View } from "react-native";
import { useMessageStore } from "../../stores/use-message-store";
import { AppFontSize } from "../../utils/size";
import { SIZE } from "../../utils/size";
import { Button } from "../ui/button";
import Heading from "../ui/typography/heading";
import { getStyle } from "./functions";
import { DefaultAppStyles } from "../../utils/styles";
export const Title = ({ text, style = {}, inline }) => {
const announcements = useMessageStore((state) => state.announcements);
@@ -37,19 +36,19 @@ export const Title = ({ text, style = {}, inline }) => {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: inline ? DefaultAppStyles.GAP_VERTICAL_SMALL : 0
marginBottom: inline ? 5 : 0
}}
>
<Heading
style={{
marginHorizontal: DefaultAppStyles.GAP,
marginTop: DefaultAppStyles.GAP_VERTICAL,
marginHorizontal: 12,
marginTop: 12,
...getStyle(style),
textAlign: inline ? "left" : style?.textAlign,
flexShrink: 1
}}
numberOfLines={1}
size={inline ? AppFontSize.md : AppFontSize.xl}
size={inline ? SIZE.md : SIZE.xl}
>
{inline ? text?.toUpperCase() : text}
</Heading>
@@ -68,7 +67,7 @@ export const Title = ({ text, style = {}, inline }) => {
right: 0
}}
iconSize={24}
fontSize={AppFontSize.xs}
fontSize={SIZE.xs}
style={{
borderRadius: 100,
paddingVertical: 0,
@@ -81,9 +80,9 @@ export const Title = ({ text, style = {}, inline }) => {
) : (
<Heading
style={{
marginHorizontal: DefaultAppStyles.GAP,
marginHorizontal: 12,
...getStyle(style),
marginTop: style?.marginTop || DefaultAppStyles.GAP_VERTICAL
marginTop: style?.marginTop || 12
}}
>
{text}

View File

@@ -44,6 +44,7 @@ import SettingsService from "../../services/settings";
import { useSettingStore } from "../../stores/use-setting-store";
import { useUserStore } from "../../stores/use-user-store";
import { NotesnookModule } from "../../utils/notesnook-module";
import { SIZE } from "../../utils/size";
import { Toast } from "../toast";
import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
@@ -52,10 +53,6 @@ import Seperator from "../ui/seperator";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
import { strings } from "@notesnook/intl";
import { AppFontSize } from "../../utils/size";
import { editorController } from "../../screens/editor/tiptap/utils";
import { useTabStore } from "../../screens/editor/tiptap/use-tab-store";
const getUser = () => {
const user = MMKV.getString("user");
@@ -84,7 +81,7 @@ const verifyUserPassword = async (password: string) => {
}
};
const AppLocked = () => {
const AppLockedOverlay = () => {
const initialLaunchBiometricRequest = useRef(true);
const { colors } = useThemeColors();
const user = getUser();
@@ -111,25 +108,17 @@ const AppLocked = () => {
);
const onUnlockAppRequested = useCallback(async () => {
editorController.current?.commands.blur(useTabStore.getState().currentTab);
editorController.current?.commands.blurPassInput();
if (
!biometricsAuthEnabled ||
!(await BiometricService.isBiometryAvailable())
) {
)
return;
}
biometricUnlockAwaitingUserInput.current = true;
if (Platform.OS === "android") {
const activityName = await NotesnookModule.getActivityName();
if (
activityName !== "MainActivity" &&
activityName !== "NotePreviewConfigureActivity"
)
return;
if (activityName !== "MainActivity") return;
}
useSettingStore.getState().setRequestBiometrics(true);
@@ -168,7 +157,7 @@ const AppLocked = () => {
password.current = undefined;
} else {
ToastManager.show({
heading: strings.invalid(keyboardType),
heading: `Invalid ${keyboardType === "numeric" ? "pin" : "password"}`,
type: "error",
context: "local"
});
@@ -187,15 +176,10 @@ const AppLocked = () => {
(prevState === "background" || initialLaunchBiometricRequest.current)
) {
if (SettingsService.shouldLockAppOnEnterForeground()) {
if (useSettingStore.getState().appDidEnterBackgroundForAction) {
useSettingStore.getState().setAppDidEnterBackgroundForAction(false);
return;
}
DatabaseLogger.info("Locking app on entering foreground");
useUserStore.getState().lockApp(true);
}
if (
!(
biometricUnlockAwaitingUserInput.current ||
@@ -266,7 +250,7 @@ const AppLocked = () => {
textAlign: "center"
}}
>
{strings.unlockNotes()}
Unlock your notes
</Heading>
<Paragraph
@@ -276,7 +260,7 @@ const AppLocked = () => {
maxWidth: "90%"
}}
>
{strings.verifyItsYou()}
{"Please verify it's you"}
</Paragraph>
<Seperator />
<View
@@ -294,23 +278,13 @@ const AppLocked = () => {
keyboardType={
appLockHasPasswordSecurity ? keyboardType : "default"
}
onLayout={async () => {
if (
!biometricsAuthEnabled ||
!(await BiometricService.isBiometryAvailable())
) {
setTimeout(() => {
passwordInputRef.current?.focus();
}, 32);
}
}}
placeholder={
placeholder={`Enter ${
appLockHasPasswordSecurity
? keyboardType === "numeric"
? strings.enterApplockPassword()
: strings.enterApplockPin()
: strings.enterAccountPassword()
}
? `app lock ${
keyboardType === "numeric" ? "pin" : "password"
}`
: "account password"
}`}
onChangeText={(v) => (password.current = v)}
onSubmit={() => {
onSubmit();
@@ -327,7 +301,7 @@ const AppLocked = () => {
{user || appLockHasPasswordSecurity ? (
<>
<Button
title={strings.continue()}
title="Continue"
type="accent"
onPress={onSubmit}
width={250}
@@ -336,14 +310,14 @@ const AppLocked = () => {
borderRadius: 150,
marginBottom: 10
}}
fontSize={AppFontSize.md}
fontSize={SIZE.md}
/>
</>
) : null}
{biometricsAuthEnabled ? (
<Button
title={strings.unlockWithBiometrics()}
title="Unlock with Biometrics"
width={250}
onPress={onUnlockAppRequested}
icon={"fingerprint"}
@@ -357,4 +331,4 @@ const AppLocked = () => {
) : null;
};
export default AppLocked;
export default AppLockedOverlay;

View File

@@ -30,8 +30,6 @@ import filesystem from "../../common/filesystem";
import downloadAttachment from "../../common/filesystem/download-attachment";
import { useAttachmentProgress } from "../../hooks/use-attachment-progress";
import picker from "../../screens/editor/tiptap/picker";
import { useTabStore } from "../../screens/editor/tiptap/use-tab-store";
import { editorController } from "../../screens/editor/tiptap/utils";
import {
ToastManager,
eSendEvent,
@@ -45,30 +43,28 @@ import {
eDBItemUpdate,
eOnLoadNote
} from "../../utils/events";
import { AppFontSize } from "../../utils/size";
import { SIZE } from "../../utils/size";
import { sleep } from "../../utils/time";
import { Dialog } from "../dialog";
import { presentDialog } from "../dialog/functions";
import { openNote } from "../list-items/note/wrapper";
import { DateMeta } from "../properties/date-meta";
import SheetProvider from "../sheet-provider";
import { Button } from "../ui/button";
import { Notice } from "../ui/notice";
import { Pressable } from "../ui/pressable";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { strings } from "@notesnook/intl";
import { DefaultAppStyles } from "../../utils/styles";
import { useTabStore } from "../../screens/editor/tiptap/use-tab-store";
import { editorController } from "../../screens/editor/tiptap/utils";
import SheetProvider from "../sheet-provider";
const Actions = ({
attachment,
close,
setAttachments,
fwdRef,
context
fwdRef
}: {
attachment: Attachment;
context: string;
setAttachments: (attachments?: VirtualizedGrouping<Attachment>) => void;
close?: () => void;
fwdRef: RefObject<ActionSheetRef>;
@@ -82,25 +78,26 @@ const Actions = ({
const [loading, setLoading] = useState<{
name?: string;
}>({});
const actions = [
{
name: strings.network.download(),
name: "Download",
onPress: async () => {
if (currentProgress) {
await db.fs().cancel(attachment.hash);
useAttachmentStore.getState().remove(attachment.hash);
}
downloadAttachment(attachment.hash, context === "global");
fwdRef.current?.hide();
downloadAttachment(attachment.hash, false);
eSendEvent(eCloseSheet, contextId);
},
icon: "download"
},
{
name: strings.network.reupload(),
name: "Reupload",
onPress: async () => {
if (!PremiumService.get()) {
ToastManager.show({
heading: strings.upgradeToPro(),
heading: "Upgrade to pro",
type: "error",
context: "local"
});
@@ -116,19 +113,17 @@ const Actions = ({
icon: "upload"
},
{
name: strings.fileCheck(),
name: "Run file check",
onPress: async () => {
setLoading({
name: strings.fileCheck()
name: "Run file check"
});
const result = await filesystem.checkAttachment(attachment.hash);
if (!result) return;
if (result.failed) {
db.attachments.markAsFailed(attachment.id, result.failed);
setFailed(result.failed);
const res = await filesystem.checkAttachment(attachment.hash);
if (res.failed) {
db.attachments.markAsFailed(attachment.id, res.failed);
setFailed(res.failed);
ToastManager.show({
heading: strings.fileCheckFailed(result.failed),
heading: "File check failed with error: " + res.failed,
type: "error",
context: "local"
});
@@ -137,7 +132,7 @@ const Actions = ({
db.attachments.markAsFailed(attachment.id);
eSendEvent(eDBItemUpdate, attachment.id);
ToastManager.show({
heading: strings.fileCheckPassed(),
heading: "File check passed",
type: "success",
context: "local"
});
@@ -151,11 +146,13 @@ const Actions = ({
icon: "file-check"
},
{
name: strings.rename(),
name: "Rename",
onPress: () => {
presentDialog({
context: contextId as any,
input: true,
title: strings.renameFile(),
title: "Rename file",
paragraph: "Enter a new name for the file",
defaultValue: attachment.filename,
positivePress: async (value) => {
if (value && value.length > 0) {
@@ -167,15 +164,14 @@ const Actions = ({
setAttachments();
eSendEvent(eDBItemUpdate, attachment.id);
}
return true;
},
positiveText: strings.rename()
positiveText: "Rename"
});
},
icon: "form-textbox"
},
{
name: strings.delete(),
name: "Delete",
onPress: async () => {
const relations = await db.relations.to(attachment, "note").get();
await db.attachments.remove(attachment.hash, false);
@@ -184,17 +180,18 @@ const Actions = ({
relations
.map((relation) => relation.fromId)
.forEach(async (id) => {
useTabStore.getState().forEachNoteTab(id, async (tab) => {
const isFocused = useTabStore.getState().currentTab === tab.id;
const tab = useTabStore.getState().getTabForNote(id);
if (tab !== undefined) {
const isFocused = useTabStore.getState().currentTab === tab;
if (isFocused) {
eSendEvent(eOnLoadNote, {
item: await db.notes.note(id),
forced: true
});
} else {
editorController.current.commands.setLoading(true, tab.id);
editorController.current.commands.setLoading(true, tab);
}
});
}
});
close?.();
},
@@ -220,7 +217,6 @@ const Actions = ({
style={{
maxHeight: "100%"
}}
keyboardShouldPersistTaps="never"
>
<Dialog context={contextId} />
<SheetProvider context={contextId} />
@@ -228,15 +224,14 @@ const Actions = ({
style={{
borderBottomWidth: 1,
borderBottomColor: colors.primary.border,
marginBottom:
notes && notes.length > 0 ? 0 : DefaultAppStyles.GAP_VERTICAL
marginBottom: notes && notes.length > 0 ? 0 : 12
}}
>
<Heading
style={{
paddingHorizontal: DefaultAppStyles.GAP
paddingHorizontal: 12
}}
size={AppFontSize.lg}
size={SIZE.lg}
>
{filename}
</Heading>
@@ -244,22 +239,23 @@ const Actions = ({
<View
style={{
flexDirection: "row",
marginBottom: DefaultAppStyles.GAP_VERTICAL,
paddingHorizontal: DefaultAppStyles.GAP,
marginTop: DefaultAppStyles.GAP_VERTICAL_SMALL,
marginBottom: 10,
paddingHorizontal: 12,
marginTop: 6,
gap: 10
}}
>
<Paragraph size={AppFontSize.xs} color={colors.secondary.paragraph}>
<Paragraph size={SIZE.xs} color={colors.secondary.paragraph}>
{attachment.mimeType}
</Paragraph>
<Paragraph size={AppFontSize.xs} color={colors.secondary.paragraph}>
<Paragraph size={SIZE.xs} color={colors.secondary.paragraph}>
{formatBytes(attachment.size)}
</Paragraph>
{notes.length ? (
<Paragraph size={AppFontSize.xs} color={colors.secondary.paragraph}>
{strings.notes(notes.length)}
<Paragraph size={SIZE.xs} color={colors.secondary.paragraph}>
{notes.length} note
{notes.length > 1 ? "s" : ""}
</Paragraph>
) : null}
<Paragraph
@@ -267,11 +263,11 @@ const Actions = ({
Clipboard.setString(attachment.hash);
ToastManager.show({
type: "success",
heading: strings.hashCopied(),
heading: "Attachment hash copied",
context: "local"
});
}}
size={AppFontSize.xs}
size={SIZE.xs}
color={colors.secondary.paragraph}
>
{attachment.hash}
@@ -286,18 +282,18 @@ const Actions = ({
style={{
borderBottomWidth: 1,
borderBottomColor: colors.primary.border,
marginBottom: DefaultAppStyles.GAP_VERTICAL,
paddingVertical: DefaultAppStyles.GAP_VERTICAL
marginBottom: 12,
paddingVertical: 12
}}
>
<>
<Heading
style={{
paddingHorizontal: DefaultAppStyles.GAP
paddingHorizontal: 12
}}
size={AppFontSize.sm}
size={SIZE.sm}
>
{strings.listOf()} {strings.dataTypesPlural.note()}:
List of notes:
</Heading>
{notes.map((item) => (
@@ -310,14 +306,14 @@ const Actions = ({
openNote(item, (item as any).type === "trash");
}}
style={{
paddingVertical: DefaultAppStyles.GAP_VERTICAL,
paddingVertical: 12,
alignItems: "flex-start",
paddingHorizontal: DefaultAppStyles.GAP
paddingHorizontal: 12
}}
key={item.id}
>
<Paragraph size={AppFontSize.xs}>{item.title}</Paragraph>
<Paragraph size={SIZE.xs}>{item.title}</Paragraph>
</Pressable>
))}
</>
@@ -338,7 +334,7 @@ const Actions = ({
icon={item.icon}
loading={loading?.name === item.name}
type="plain"
fontSize={AppFontSize.sm}
fontSize={SIZE.sm}
style={{
borderRadius: 0,
justifyContent: "flex-start",
@@ -350,13 +346,13 @@ const Actions = ({
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP
paddingHorizontal: 12
}}
>
{failed ? (
<Notice
type="alert"
text={strings.fileCheckFailed(failed)}
text={`File check failed: ${failed} Try reuploading the file to fix the issue.`}
size="small"
/>
) : null}
@@ -378,7 +374,6 @@ Actions.present = (
setAttachments={set}
close={close}
attachment={attachment}
context={context || "global"}
/>
)
});

View File

@@ -22,17 +22,15 @@ import { Attachment, VirtualizedGrouping } from "@notesnook/core";
import { useThemeColors } from "@notesnook/theme";
import React from "react";
import { TouchableOpacity, View } from "react-native";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { db } from "../../common/database";
import { useAttachmentProgress } from "../../hooks/use-attachment-progress";
import { useDBItem } from "../../hooks/use-db-item";
import { defaultBorderRadius, AppFontSize } from "../../utils/size";
import { SIZE } from "../../utils/size";
import { IconButton } from "../ui/icon-button";
import { ProgressCircleComponent } from "../ui/svg/lazy";
import Paragraph from "../ui/typography/paragraph";
import Actions from "./actions";
import { strings } from "@notesnook/intl";
import { Pressable } from "../ui/pressable";
import { DefaultAppStyles } from "../../utils/styles";
function getFileExtension(filename: string) {
const ext = /^.+\.([^.]+)$/.exec(filename);
@@ -46,8 +44,7 @@ export const AttachmentItem = ({
setAttachments,
pressable = true,
hideWhenNotDownloading,
context,
errorOnly
context
}: {
id: string | number;
attachments?: VirtualizedGrouping<Attachment>;
@@ -56,9 +53,9 @@ export const AttachmentItem = ({
pressable?: boolean;
hideWhenNotDownloading?: boolean;
context?: string;
errorOnly?: boolean;
}) => {
const [attachment] = useDBItem(id, "attachment", attachments);
const { colors } = useThemeColors();
const [currentProgress, setCurrentProgress] = useAttachmentProgress(
attachment,
@@ -70,15 +67,19 @@ export const AttachmentItem = ({
Actions.present(attachment, setAttachments, context);
};
return errorOnly && attachment && !attachment?.failed ? null : (
<Pressable
return hideWhenNotDownloading &&
(!currentProgress || !currentProgress.value) ? null : (
<TouchableOpacity
activeOpacity={0.9}
onPress={onPress}
style={{
flexDirection: "row",
marginVertical: 5,
justifyContent: "space-between",
padding: DefaultAppStyles.GAP,
paddingVertical: DefaultAppStyles.GAP_VERTICAL,
padding: 12,
paddingVertical: 6,
borderRadius: 5,
backgroundColor: colors.secondary.background,
minHeight: 45
}}
>
@@ -88,46 +89,41 @@ export const AttachmentItem = ({
style={{
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
gap: 10
alignItems: "center"
}}
>
<View
style={{
justifyContent: "center",
alignItems: "center",
marginLeft: -5,
borderWidth: 1,
borderColor: colors.secondary.border,
paddingHorizontal: 2,
width: 30,
height: 30,
borderRadius: defaultBorderRadius
marginLeft: -5
}}
>
<Icon name="file" size={SIZE.xxxl} color={colors.primary.icon} />
<Paragraph
adjustsFontSizeToFit
size={8}
color={colors.secondary.paragraph}
size={6}
color={colors.static.white}
style={{
maxWidth: 30
position: "absolute"
}}
numberOfLines={1}
>
{getFileExtension(attachment.filename).toUpperCase() ||
attachment.mimeType.split("/")?.[1]?.toUpperCase()}
{getFileExtension(attachment.filename).toUpperCase()}
</Paragraph>
</View>
<View
style={{
flexShrink: 1
flexShrink: 1,
marginLeft: 10
}}
>
<Paragraph
size={AppFontSize.sm}
size={SIZE.sm - 1}
style={{
flexWrap: "wrap"
flexWrap: "wrap",
marginBottom: 2.5
}}
numberOfLines={1}
lineBreakMode="middle"
@@ -137,11 +133,11 @@ export const AttachmentItem = ({
</Paragraph>
{!hideWhenNotDownloading ? (
<Paragraph
color={colors.secondary.paragraph}
size={AppFontSize.xxs}
>
{strings.fileSize()}: {formatBytes(attachment.size)}
<Paragraph color={colors.secondary.paragraph} size={SIZE.xs}>
{formatBytes(attachment.size)}{" "}
{currentProgress?.type
? "(" + currentProgress.type + "ing - tap to cancel)"
: ""}
</Paragraph>
) : null}
</View>
@@ -163,7 +159,7 @@ export const AttachmentItem = ({
}}
>
<ProgressCircleComponent
size={AppFontSize.xxl}
size={SIZE.xxl}
progress={
currentProgress?.value ? currentProgress?.value / 100 : 0
}
@@ -190,6 +186,6 @@ export const AttachmentItem = ({
)}
</>
)}
</Pressable>
</TouchableOpacity>
);
};

View File

@@ -0,0 +1,297 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useRef, useState } from "react";
import { Platform, View } from "react-native";
import { db } from "../../common/database";
import { downloadAttachments } from "../../common/filesystem/download-attachment";
import {
PresentSheetOptions,
presentSheet
} from "../../services/event-manager";
import { Button } from "../ui/button";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { ProgressBarComponent } from "../ui/svg/lazy";
import { useThemeColors } from "@notesnook/theme";
import { FlatList } from "react-native-actions-sheet";
import { AttachmentItem } from "./attachment-item";
import { Attachment, VirtualizedGrouping } from "@notesnook/core";
const DownloadAttachments = ({
close,
attachments,
isNote,
update
}: {
attachments: VirtualizedGrouping<Attachment>;
close?: ((ctx?: string | undefined) => void) | undefined;
isNote?: boolean;
update?: (props: PresentSheetOptions) => void;
}) => {
const { colors } = useThemeColors();
const [downloading, setDownloading] = useState(false);
const [progress, setProgress] = useState({
value: 0,
statusText: "Download started... Please wait"
});
const [result, setResult] = useState(new Map());
const canceled = useRef(false);
const groupId = useRef<string>();
const onDownload = async () => {
update?.({
disableClosing: true
} as PresentSheetOptions);
setDownloading(true);
canceled.current = false;
groupId.current = Date.now().toString();
const result = await downloadAttachments(
await attachments.ids(),
(progress: number, statusText: string) =>
setProgress({ value: progress, statusText }),
canceled,
groupId.current
);
if (canceled.current) return;
setResult(result || new Map());
setDownloading(false);
update?.({
disableClosing: false
} as PresentSheetOptions);
};
const cancel = async () => {
update?.({
disableClosing: false
} as PresentSheetOptions);
canceled.current = true;
if (!groupId.current) return;
console.log(groupId.current, "canceling groupId downloads");
await db.fs().cancel(groupId.current);
setDownloading(false);
setResult(new Map());
groupId.current = undefined;
};
const failedResults = () => {
const results = [];
for (const value of result.values()) {
if (value.status === 0) results.push(value.attachment);
}
return results;
};
function getResultText() {
const downloadedAttachmentsCount =
attachments?.placeholders?.length - failedResults().length;
if (downloadedAttachmentsCount === 0)
return "Failed to download all attachments";
return `Successfully downloaded ${downloadedAttachmentsCount}/${
attachments?.placeholders.length
} attachments as a zip file at ${
Platform.OS === "android" ? "the selected folder" : "Notesnook/downloads"
}`;
}
return (
<View
style={{
alignItems: "center",
width: "100%",
paddingVertical: 12,
paddingHorizontal: 12
}}
>
<Heading>
{downloading
? "Downloading attachments"
: result?.size
? "Downloaded attachments"
: "Download attachments"}
</Heading>
{downloading ? (
<Paragraph
style={{
textAlign: "center"
}}
>
{progress.statusText}
</Paragraph>
) : result?.size ? (
<Paragraph
style={{
textAlign: "center"
}}
>
{getResultText()}
</Paragraph>
) : (
<Paragraph
style={{
textAlign: "center"
}}
>
Are you sure you want to download all attachments
{isNote ? " of this note?" : "?"}
</Paragraph>
)}
{downloading ? (
<View
style={{
width: 200,
marginTop: 10
}}
>
<ProgressBarComponent
height={5}
width={null}
animated={true}
useNativeDriver
progress={
progress.value
? progress.value / attachments.placeholders?.length
: 0
}
unfilledColor={colors.secondary.background}
color={colors.primary.accent}
borderWidth={0}
/>
</View>
) : null}
<FlatList
style={{
maxHeight: 300,
width: "100%",
minHeight: 60,
backgroundColor: colors.secondary.background,
borderRadius: 5,
marginVertical: 12
}}
data={downloading ? attachments.placeholders : undefined}
ListEmptyComponent={
<View
style={{
width: "100%",
justifyContent: "center",
alignItems: "center",
height: 60
}}
>
<Paragraph color={colors.secondary.paragraph}>
No downloads in progress.
</Paragraph>
</View>
}
keyExtractor={(index) => "attachment_download" + index}
renderItem={({ index }) => {
return (
<AttachmentItem
id={index}
setAttachments={() => {}}
pressable={false}
hideWhenNotDownloading={true}
attachments={attachments}
/>
);
}}
/>
{result?.size ? (
<Button
style={{
width: 250,
borderRadius: 100,
marginTop: 20
}}
onPress={() => {
close?.();
}}
type="accent"
title="Done"
/>
) : !downloading ? (
<View
style={{
flexDirection: "row",
width: "100%",
marginTop: 20
}}
>
<Button
style={{
flex: 1,
borderRadius: 100,
marginRight: 5
}}
onPress={() => {
close?.();
}}
type="secondary"
title="No"
/>
<Button
style={{
flex: 1,
borderRadius: 100,
marginLeft: 5
}}
onPress={onDownload}
type="accent"
title="Yes"
/>
</View>
) : (
<Button
style={{
width: 250,
borderRadius: 100,
marginTop: 20
}}
onPress={cancel}
type="error"
title="Cancel"
/>
)}
</View>
);
};
DownloadAttachments.present = (
context: string,
attachments: VirtualizedGrouping<Attachment>,
isNote?: boolean
) => {
presentSheet({
context: context,
component: (ref, close, update) => (
<DownloadAttachments
close={close}
attachments={attachments}
isNote={isNote}
update={update}
/>
)
});
};
export default DownloadAttachments;

View File

@@ -19,29 +19,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import {
Attachment,
FilteredSelector,
Note,
SortOptions,
VirtualizedGrouping
} from "@notesnook/core";
import { strings } from "@notesnook/intl";
import { FilteredSelector } from "@notesnook/core/dist/database/sql-collection";
import { useThemeColors } from "@notesnook/theme";
import React, { useEffect, useRef, useState } from "react";
import { ActivityIndicator, View } from "react-native";
import { ActivityIndicator, ScrollView, View } from "react-native";
import { FlashList } from "react-native-actions-sheet/dist/src/views/FlashList";
import { ScrollView } from "react-native-gesture-handler";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import create, { State } from "zustand";
import { db } from "../../common/database";
import filesystem from "../../common/filesystem";
import { downloadAttachments } from "../../common/filesystem/download-attachment";
import { AttachmentGroupProgress } from "../../screens/settings/attachment-group-progress";
import { presentSheet, ToastManager } from "../../services/event-manager";
import { useAttachmentStore } from "../../stores/use-attachment-store";
import { AppFontSize } from "../../utils/size";
import { Dialog } from "../dialog";
import { presentDialog } from "../dialog/functions";
import { Header } from "../header";
import { presentSheet } from "../../services/event-manager";
import { useSettingStore } from "../../stores/use-setting-store";
import { SIZE } from "../../utils/size";
import SheetProvider from "../sheet-provider";
import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
@@ -50,106 +42,22 @@ import Seperator from "../ui/seperator";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { AttachmentItem } from "./attachment-item";
import { DefaultAppStyles } from "../../utils/styles";
import DownloadAttachments from "./download-attachments";
const DEFAULT_SORTING: SortOptions = {
sortBy: "dateEdited",
sortDirection: "desc"
};
type RecheckerProgress = {
failed: number;
passed: number;
isWorking: boolean;
shown: boolean;
filter: string;
};
interface RecheckerState extends State {
progress: {
[key: string]: RecheckerProgress;
};
setProgress: (key: string, progress: RecheckerProgress) => void;
currentFilter: string;
}
const useRechecker = create<RecheckerState>((set) => ({
progress: {
all: {
failed: 0,
passed: 0,
isWorking: false,
shown: false,
filter: "all"
}
},
currentFilter: "all",
setProgress: (key: string, progress: RecheckerProgress) => {
set((state) => ({
progress: {
...state.progress,
[key]: progress
}
}));
}
}));
const attachmentTypes = [
{
title: strings.mediaTypes.all(),
filterBy: "all"
},
{
title: strings.mediaTypes.image(),
filterBy: "images"
},
{
title: strings.mediaTypes.audio(),
filterBy: "audio"
},
{
title: strings.mediaTypes.video(),
filterBy: "video"
},
{
title: strings.mediaTypes.document(),
filterBy: "documents"
},
{
title: strings.mediaTypes.orphaned(),
filterBy: "orphaned"
},
{
title: strings.mediaTypes.errors(),
filterBy: "errors"
}
];
export const AttachmentDialog = ({
note,
isSheet
}: {
note?: Note;
isSheet: boolean;
}) => {
export const AttachmentDialog = ({ note }: { note?: Note }) => {
const { colors } = useThemeColors();
const { height } = useSettingStore((state) => state.dimensions);
const [attachments, setAttachments] =
useState<VirtualizedGrouping<Attachment>>();
const attachmentSearchValue = useRef<string>();
const [loading, setLoading] = useState(true);
const searchTimer = useRef<NodeJS.Timeout>();
const [loading, setLoading] = useState(false);
const [currentFilter, setCurrentFilter] = useState("all");
const rechecker = useRechecker((state) =>
note ? state.progress[note.id] || {} : state.progress.all
);
const currentFilterRef = useRef(currentFilter);
currentFilterRef.current = currentFilter;
useEffect(() => {
useRechecker.setState({
currentFilter: currentFilter
});
}, [currentFilter]);
const refresh = React.useCallback(() => {
if (note) {
@@ -160,7 +68,6 @@ export const AttachmentDialog = ({
sortBy: "dateModified"
})
.then((attachments) => {
setLoading(false);
setAttachments(attachments);
});
} else {
@@ -169,10 +76,7 @@ export const AttachmentDialog = ({
...DEFAULT_SORTING,
sortBy: "dateModified"
})
.then((attachments) => {
setAttachments(attachments);
setLoading(false);
});
.then((attachments) => setAttachments(attachments));
}
}, [note]);
@@ -204,71 +108,53 @@ export const AttachmentDialog = ({
setAttachments={async () => {
setAttachments(await filterAttachments(currentFilter));
}}
errorOnly={currentFilter === "errors"}
attachments={attachments}
id={index}
context={!isSheet ? "global" : "attachments-list"}
context="attachments-list"
/>
);
const onCheck = async () => {
const getState = () =>
useRechecker.getState().progress[note?.id || "all"] || {};
const setState = (state: Partial<RecheckerProgress>) =>
useRechecker.getState().setProgress(note?.id || "all", {
...getState(),
...state
});
if (!attachments) return;
setLoading(true);
if (!attachments || getState().isWorking) return;
setState({
isWorking: true,
failed: 0,
passed: 0,
shown: true,
filter: currentFilterRef.current
});
const filter = currentFilterRef.current;
const filteredAttachments = await filterAttachments(
currentFilterRef.current
);
for (let i = 0; i < filteredAttachments.placeholders.length; i++) {
if (!getState().isWorking) {
ToastManager.show({
message: strings.attachmentRecheckCancelled(),
type: "info",
context: isSheet ? "local" : "global"
});
return;
}
for (let i = 0; i < attachments.placeholders.length; i++) {
const attachment = (await attachments.item(i))?.item;
if (currentFilter == "errors" && !attachment?.failed) continue;
if (!attachment) continue;
const result = await filesystem.checkAttachment(attachment.hash);
if (!result) return;
if (result.failed) {
setState({
failed: getState().failed + 1
});
await db.attachments.markAsFailed(attachment.hash, result.failed);
} else {
setState({
passed: getState().passed + 1
});
await db.attachments.markAsFailed(attachment.id);
await db.attachments.markAsFailed(attachment.id, undefined);
}
}
if (filter === useRechecker.getState().currentFilter) {
setAttachments(await filterAttachments(currentFilter));
}
setState({
isWorking: false
});
refresh();
setLoading(false);
};
const attachmentTypes = [
{
title: "All",
filterBy: "all"
},
{
title: "Images",
filterBy: "images"
},
{
title: "Documents",
filterBy: "documents"
},
{
title: "Video",
filterBy: "video"
},
{
title: "Audio",
filterBy: "audio"
}
];
const filterAttachments = async (type: string) => {
let items: FilteredSelector<Attachment> = db.attachments.all;
@@ -297,14 +183,6 @@ export const AttachmentDialog = ({
items = note
? db.attachments.ofNote(note.id, "documents")
: db.attachments.documents;
break;
case "orphaned":
items = db.attachments.orphaned;
break;
case "errors":
items = items = note
? db.attachments.ofNote(note.id, "all")
: db.attachments.all;
}
return await items.sorted({
@@ -314,303 +192,170 @@ export const AttachmentDialog = ({
};
return (
<>
{isSheet ? (
<>
<SheetProvider context="attachments-list" />
<Dialog context="local" />
</>
) : null}
{!isSheet ? (
<Header
title={strings.manageAttachments()}
renderedInRoute="SettingsGroup"
canGoBack
// TODO: Add headerRightButtons
// headerRightButtons={[
// {
// onPress() {
// onCheck();
// },
// title: strings.recheckAll()
// },
// {
// onPress() {
// if (!attachments) return;
// presentDialog({
// title: strings.doActions.download.attachment(
// attachments.placeholders.length
// ),
// positiveText: strings.network.download(),
// positivePress: async () => {
// downloadAttachments(await attachments.ids());
// },
// negativeText: strings.cancel()
// });
// },
// title: strings.downloadAllAttachments()
// }
// ]}
/>
) : (
<View
style={{
width: "100%",
alignSelf: "center",
paddingHorizontal: 12,
height: height * 0.85
}}
>
<SheetProvider context="attachments-list" />
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center"
}}
>
<Heading>Attachments</Heading>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: DefaultAppStyles.GAP
flexDirection: "row"
}}
>
<Heading>{strings.dataTypesPluralCamelCase.attachment()}</Heading>
<View
style={{
flexDirection: "row"
}}
>
{loading ? (
<ActivityIndicator
style={{
height: 40,
width: 40,
marginRight: 10
}}
size={SIZE.lg}
/>
) : (
<IconButton
name="check-all"
style={{
height: 40,
width: 40,
marginRight: 10
}}
color={colors.primary.paragraph}
size={AppFontSize.lg}
size={SIZE.lg}
onPress={onCheck}
/>
)}
<IconButton
name="download"
color={colors.primary.paragraph}
onPress={() => {
if (!attachments) return;
presentDialog({
title: strings.doActions.download.attachment(
attachments.placeholders.length
),
positiveText: strings.network.download(),
positivePress: async () => {
downloadAttachments(await attachments.ids());
},
negativeText: strings.cancel()
});
}}
size={AppFontSize.lg}
/>
</View>
<IconButton
name="download"
style={{
height: 40,
width: 40
}}
color={colors.primary.paragraph}
onPress={() => {
if (!attachments) return;
DownloadAttachments.present(
"attachments-list",
attachments,
!!note
);
}}
size={SIZE.lg}
/>
</View>
)}
</View>
<View
style={{
width: "100%",
alignSelf: "center",
paddingHorizontal: DefaultAppStyles.GAP,
height: "100%"
<Seperator />
<Input
placeholder="Filter attachments by filename, type or hash"
onChangeText={onChangeText}
onSubmit={() => {
onChangeText(attachmentSearchValue.current as string);
}}
>
<Seperator />
<Input
placeholder={strings.filterAttachments()}
onChangeText={onChangeText}
onSubmit={() => {
onChangeText(attachmentSearchValue.current as string);
}}
/>
/>
{rechecker.shown ? (
<View>
<ScrollView
style={{
width: "100%",
height: 50,
flexDirection: "row",
backgroundColor: colors.primary.background
}}
contentContainerStyle={{
minWidth: "100%",
height: 50
}}
horizontal
>
{attachmentTypes.map((item) => (
<Button
type={
currentFilter === item.filterBy ? "secondaryAccented" : "plain"
}
key={item.title}
title={item.title}
style={{
borderRadius: 0,
borderBottomWidth: 1,
flexGrow: 1,
borderBottomColor:
currentFilter !== item.filterBy
? "transparent"
: colors.primary.accent
}}
onPress={async () => {
setCurrentFilter(item.filterBy);
setAttachments(await filterAttachments(item.filterBy));
}}
/>
))}
</ScrollView>
</View>
<FlashList
keyboardDismissMode="none"
keyboardShouldPersistTaps="always"
ListEmptyComponent={
<View
style={{
flexDirection: "row",
alignItems: "center",
width: "100%",
borderRadius: 10,
padding: DefaultAppStyles.GAP_SMALL,
borderWidth: 1,
borderColor: colors.primary.border,
gap: 12,
justifyContent: "space-between"
}}
>
<View
style={{
flexDirection: "row",
gap: 12,
alignItems: "center"
}}
>
{rechecker.isWorking ? (
<ActivityIndicator color={colors.primary.accent} />
) : (
<Icon
name="file-check"
size={30}
color={colors.primary.accent}
/>
)}
<View>
<Paragraph>
{rechecker.isWorking
? note
? strings.checkingNoteAttachments()
: strings.checkingAllAttachments()
: strings.attachmentRecheckComplete()}
</Paragraph>
<Paragraph>
{`${
rechecker.isWorking ? `${strings.pleaseWait()} ` : ""
}${strings.passed()}: ${
rechecker.passed
}, ${strings.failed()}: ${rechecker.failed}`}
</Paragraph>
</View>
</View>
<IconButton
type={rechecker.isWorking ? "errorShade" : "plain"}
name={rechecker.isWorking ? "close" : "check"}
size={AppFontSize.lg}
color={
rechecker.isWorking ? colors.error.icon : colors.primary.accent
}
onPress={() => {
useRechecker.getState().setProgress(note?.id || "all", {
...useRechecker.getState().progress[note?.id || "all"],
shown: false,
isWorking: false
});
}}
/>
</View>
) : null}
<View>
<ScrollView
style={{
backgroundColor: colors.primary.background,
flexWrap: "wrap",
flexDirection: "row",
paddingVertical: DefaultAppStyles.GAP_VERTICAL
}}
contentContainerStyle={{
height: 150,
justifyContent: "center",
alignItems: "center"
}}
horizontal
>
{attachmentTypes.map((item) =>
item.filterBy === "orphaned" && note ? null : (
<Button
type={
currentFilter === item.filterBy
? "secondaryAccented"
: "plain"
}
key={item.title}
title={item.title}
fontSize={AppFontSize.sm}
style={{
borderRadius: 100,
paddingHorizontal: DefaultAppStyles.GAP,
height: 40,
minWidth: 80
}}
onPress={async () => {
const filterBy = item.filterBy;
setCurrentFilter(filterBy);
setLoading(true);
filterAttachments(filterBy)
.then((results) => {
if (filterBy !== currentFilterRef.current) return;
setAttachments(results);
setLoading(false);
})
.catch(() => {
/* empty */
});
}}
/>
)
)}
</ScrollView>
</View>
<FlashList
keyboardDismissMode="none"
keyboardShouldPersistTaps="always"
ListEmptyComponent={
<View
style={{
height: "100%",
justifyContent: "center",
alignItems: "center"
}}
>
{loading ? (
<ActivityIndicator size={40} color={colors.primary.accent} />
) : (
<>
<Icon
name="attachment"
size={60}
color={colors.secondary.icon}
/>
<Paragraph>{strings.noAttachments()}</Paragraph>
</>
)}
</View>
}
ListHeaderComponent={<AllProgress note={note} />}
ListFooterComponent={
<View
style={{
height: 350
}}
/>
}
estimatedItemSize={50}
data={loading ? [] : attachments?.placeholders}
renderItem={renderItem}
/>
</View>
</>
);
};
const AllProgress = ({ note }: { note?: Note }) => {
const progress = useAttachmentStore((state) =>
note
? {
downloading: {
[note.id]: state.downloading?.[note.id]
},
uploading: {
[note.id]: state.uploading?.[note.id]
}
<Icon name="attachment" size={60} color={colors.secondary.icon} />
<Paragraph>
{note ? "No attachments on this note" : "No attachments"}
</Paragraph>
</View>
}
: state
);
ListFooterComponent={
<View
style={{
height: 350
}}
/>
}
estimatedItemSize={50}
data={attachments?.placeholders}
renderItem={renderItem}
/>
return (
<View
style={{
gap: 10,
width: "100%"
}}
>
{Object.keys(progress.downloading || {}).map((groupId) => (
<AttachmentGroupProgress key={groupId} groupId={groupId} />
))}
{Object.keys(progress.uploading || {}).map((groupId) => (
<AttachmentGroupProgress key={groupId} groupId={groupId} />
))}
<Paragraph
color={colors.secondary.paragraph}
size={SIZE.xs}
style={{
textAlign: "center",
marginTop: 10
}}
>
<Icon
name="shield-key-outline"
size={SIZE.xs}
color={colors.primary.icon}
/>
{" "}All attachments are end-to-end encrypted.
</Paragraph>
</View>
);
};
AttachmentDialog.present = (note?: Note) => {
presentSheet({
component: () => <AttachmentDialog note={note} isSheet={true} />,
component: () => <AttachmentDialog note={note} />,
keyboardHandlerDisabled: true
});
};

View File

@@ -36,8 +36,6 @@ import { IconButton } from "../ui/icon-button";
import { hideAuth, initialAuthMode } from "./common";
import { Login } from "./login";
import { Signup } from "./signup";
import { strings } from "@notesnook/intl";
import { DefaultAppStyles } from "../../utils/styles";
export const AuthMode = {
login: 0,
@@ -134,7 +132,7 @@ const AuthModal = () => {
style={{
flexDirection: "row",
alignItems: "center",
paddingHorizontal: DefaultAppStyles.GAP,
paddingHorizontal: 12,
width: "100%",
height: 50,
justifyContent:
@@ -155,7 +153,7 @@ const AuthModal = () => {
{initialAuthMode.current !== AuthMode.welcomeSignup ? null : (
<Button
title={strings.skip()}
title="Skip"
onPress={() => {
hideAuth();
}}
@@ -168,7 +166,7 @@ const AuthModal = () => {
marginTop: 2
}}
style={{
paddingHorizontal: DefaultAppStyles.GAP_SMALL
paddingHorizontal: 6
}}
/>
)}

View File

@@ -17,20 +17,24 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { strings } from "@notesnook/intl";
import React, { useRef, useState } from "react";
import { View } from "react-native";
import { db } from "../../common/database";
import BackupService from "../../services/backup";
import { eSendEvent, ToastManager } from "../../services/event-manager";
import Navigation from "../../services/navigation";
import {
eSendEvent,
presentSheet,
ToastManager
} from "../../services/event-manager";
import { useUserStore } from "../../stores/use-user-store";
import { eOpenRecoveryKeyDialog } from "../../utils/events";
import { DefaultAppStyles } from "../../utils/styles";
import { Dialog } from "../dialog";
import { eCloseSheet, eOpenRecoveryKeyDialog } from "../../utils/events";
import DialogHeader from "../dialog/dialog-header";
import { Button } from "../ui/button";
import Input from "../ui/input";
import { Notice } from "../ui/notice";
import Seperator from "../ui/seperator";
import { Dialog } from "../dialog";
import BackupService from "../../services/backup";
import { sleep } from "../../utils/time";
export const ChangePassword = () => {
const passwordInputRef = useRef();
@@ -45,8 +49,8 @@ export const ChangePassword = () => {
const changePassword = async () => {
if (!user?.isEmailConfirmed) {
ToastManager.show({
heading: strings.emailNotConfirmed(),
message: strings.emailNotConfirmedDesc(),
heading: "Email not confirmed",
message: "Please confirm your email to change account password",
type: "error",
context: "local"
});
@@ -54,8 +58,8 @@ export const ChangePassword = () => {
}
if (error || !oldPassword.current || !password.current) {
ToastManager.show({
heading: strings.allFieldsRequired(),
message: strings.allFieldsRequiredDesc(),
heading: "All fields required",
message: "Fill all the fields and try again.",
type: "error",
context: "local"
});
@@ -63,28 +67,24 @@ export const ChangePassword = () => {
}
setLoading(true);
try {
const result = await BackupService.run(
false,
"change-password-dialog",
"partial"
);
if (result.error) {
throw new Error(strings.backupFailed() + `: ${result.error}`);
}
const result = await BackupService.run(false, "change-password-dialog");
if (!result) throw new Error("Failed to create backup");
await db.user.clearSessions();
await db.user.changePassword(oldPassword.current, password.current);
ToastManager.show({
heading: strings.passwordChangedSuccessfully(),
heading: "Account password updated",
type: "success",
context: "global"
});
setLoading(false);
Navigation.goBack();
eSendEvent(eCloseSheet);
await sleep(300);
eSendEvent(eOpenRecoveryKeyDialog);
} catch (e) {
setLoading(false);
ToastManager.show({
heading: strings.passwordChangeFailed(),
heading: "Failed to change password",
message: e.message,
type: "error",
context: "local"
@@ -97,10 +97,16 @@ export const ChangePassword = () => {
<View
style={{
width: "100%",
padding: DefaultAppStyles.GAP
padding: 12
}}
>
<Dialog context="change-password-dialog" />
<DialogHeader
title="Change password"
paragraph="Enter your old and new passwords"
/>
<Seperator />
<Input
fwdRef={oldPasswordInputRef}
onChangeText={(value) => {
@@ -112,7 +118,7 @@ export const ChangePassword = () => {
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.oldPassword()}
placeholder="Old Password"
/>
<Input
@@ -121,32 +127,44 @@ export const ChangePassword = () => {
password.current = value;
}}
onErrorCheck={(e) => setError(e)}
returnKeyLabel={strings.next()}
returnKeyLabel="Next"
returnKeyType="next"
secureTextEntry
validationType="password"
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.newPassword()}
placeholder="New password"
/>
<Notice text={strings.changePasswordNotice()} type="alert" />
<Notice
text={`Changing password is an irreversible process. You will be logged out from all your devices. Please make sure you do not close the app while your password is changing and have good internet connection.`}
type="alert"
/>
<View style={{ height: 10 }} />
<Notice text={strings.changePasswordNotice2()} type="alert" />
<Notice
text={`Once your password is changed, please make sure to save the new account recovery key.`}
type="alert"
/>
<Button
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL,
marginTop: 10,
width: "100%"
}}
loading={loading}
onPress={changePassword}
type="accent"
title={loading ? null : strings.changePasswordConfirm()}
title={loading ? null : "I understand, change my password"}
/>
</View>
);
};
ChangePassword.present = () => {
presentSheet({
component: <ChangePassword />
});
};

View File

@@ -20,20 +20,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { createRef } from "react";
import { eSendEvent } from "../../services/event-manager";
import { eCloseLoginDialog } from "../../utils/events";
import Navigation from "../../services/navigation";
export const AuthMode = {
login: 0,
signup: 1,
welcomeSignup: 2,
trialSignup: 3
};
export const initialAuthMode = createRef(0);
export function hideAuth() {
eSendEvent(eCloseLoginDialog);
if (initialAuthMode.current === AuthMode.welcomeSignup) {
Navigation.replace("FluidPanelsView");
} else {
Navigation.goBack();
}
// if (initialAuthMode.current === 2) {
// Navigation.replace(
// {
// name: 'Notes'
// },
// {
// menu: true
// }
// );
// } else {
// Navigation.goBack();
// }
// tabBarRef.current?.unlock();
}

View File

@@ -32,8 +32,6 @@ import Input from "../ui/input";
import Seperator from "../ui/seperator";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { strings } from "@notesnook/intl";
import { DefaultAppStyles } from "../../utils/styles";
export const ForgotPassword = () => {
const { colors } = useThemeColors("sheet");
@@ -46,7 +44,7 @@ export const ForgotPassword = () => {
const sendRecoveryEmail = async () => {
if (!email.current || error) {
ToastManager.show({
heading: strings.emailRequired(),
heading: "Account email is required.",
type: "error",
context: "local"
});
@@ -59,15 +57,15 @@ export const ForgotPassword = () => {
lastRecoveryEmailTime &&
Date.now() - JSON.parse(lastRecoveryEmailTime) < 60000 * 3
) {
throw new Error(strings.pleaseWaitBeforeSendEmail());
throw new Error("Please wait before requesting another email");
}
await db.user.recoverAccount(email.current.toLowerCase());
SettingsService.set({
lastRecoveryEmailTime: Date.now()
});
ToastManager.show({
heading: strings.recoveryEmailSent(),
message: strings.recoveryEmailSentDesc(),
heading: "Check your email to reset password",
message: `Recovery email has been sent to ${email.current.toLowerCase()}`,
type: "success",
context: "local",
duration: 7000
@@ -77,7 +75,7 @@ export const ForgotPassword = () => {
} catch (e) {
setLoading(false);
ToastManager.show({
heading: strings.recoveryEmailFailed(),
heading: "Recovery email not sent",
message: e.message,
type: "error",
context: "local"
@@ -108,7 +106,7 @@ export const ForgotPassword = () => {
{sent ? (
<View
style={{
padding: DefaultAppStyles.GAP,
padding: 12,
justifyContent: "center",
alignItems: "center",
paddingBottom: 50
@@ -123,13 +121,13 @@ export const ForgotPassword = () => {
name="email"
size={50}
/>
<Heading>{strings.recoveryEmailSent()}</Heading>
<Heading>Recovery email sent!</Heading>
<Paragraph
style={{
textAlign: "center"
}}
>
{strings.recoveryEmailSentDesc()}
Please follow the link in the email to recover your account.
</Paragraph>
</View>
) : (
@@ -139,10 +137,13 @@ export const ForgotPassword = () => {
backgroundColor: colors.primary.background,
zIndex: 10,
width: "100%",
padding: DefaultAppStyles.GAP
padding: 12
}}
>
<DialogHeader title={strings.accountRecovery()} />
<DialogHeader
title="Account recovery"
paragraph="We will send you an email with steps on how to reset your password."
/>
<Seperator />
<Input
@@ -152,26 +153,26 @@ export const ForgotPassword = () => {
}}
defaultValue={email.current}
onErrorCheck={(e) => setError(e)}
returnKeyLabel={strings.next()}
returnKeyLabel="Next"
returnKeyType="next"
autoComplete="email"
validationType="email"
autoCorrect={false}
autoCapitalize="none"
errorMessage={strings.emailInvalid()}
placeholder={strings.email()}
errorMessage="Email is invalid"
placeholder="Email"
onSubmit={() => {}}
/>
<Button
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL,
marginTop: 10,
width: "100%"
}}
loading={loading}
onPress={sendRecoveryEmail}
type="accent"
title={loading ? null : strings.next()}
title={loading ? null : "Next"}
/>
</View>
)}

View File

@@ -17,98 +17,35 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useState } from "react";
import { View } from "react-native";
import useGlobalSafeAreaInsets from "../../hooks/use-global-safe-area-insets";
import Navigation from "../../services/navigation";
import { useNavigationFocus } from "../../hooks/use-navigation-focus";
import { Toast } from "../toast";
import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import { AuthMode, initialAuthMode } from "./common";
import { initialAuthMode } from "./common";
import { Login } from "./login";
import { Signup } from "./signup";
import { useNavigationFocus } from "../../hooks/use-navigation-focus";
import { DefaultAppStyles } from "../../utils/styles";
import { useSettingStore } from "../../stores/use-setting-store";
export const AuthMode = {
login: 0,
signup: 1,
welcomeSignup: 2,
trialSignup: 3
};
const Auth = ({ navigation, route }) => {
const [currentAuthMode, setCurrentAuthMode] = useState(
route?.params?.mode || AuthMode.login
);
const deviceMode = useSettingStore((state) => state.deviceMode);
const { colors } = useThemeColors();
const insets = useGlobalSafeAreaInsets();
initialAuthMode.current = route?.params.mode || AuthMode.login;
useNavigationFocus(navigation, {});
useNavigationFocus(navigation, {
onFocus: () => {
//tabBarRef?.current.lock();
initialAuthMode.current = route?.params.mode || AuthMode.login;
}
});
return (
<View style={{ flex: 1 }}>
<View
style={{
position: "absolute",
paddingTop: insets.top,
top: 0,
zIndex: 999,
backgroundColor:
deviceMode === "mobile" ? colors.secondary.background : null,
width: "100%"
}}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
paddingHorizontal: DefaultAppStyles.GAP,
width: "100%",
height: 50,
justifyContent:
initialAuthMode.current !== AuthMode.welcomeSignup
? "space-between"
: "flex-end"
}}
>
{initialAuthMode.current === AuthMode.welcomeSignup ? null : (
<IconButton
name="arrow-left"
onPress={() => {
if (initialAuthMode.current === 2) {
Navigation.replace("FluidPanelsView");
} else {
Navigation.goBack();
}
}}
color={colors.primary.paragraph}
/>
)}
{initialAuthMode.current !== AuthMode.welcomeSignup ? null : (
<Button
title={strings.skip()}
onPress={() => {
if (initialAuthMode.current === 2) {
Navigation.replace("FluidPanelsView");
} else {
Navigation.goBack();
}
}}
iconSize={16}
type="plain"
iconPosition="right"
icon="chevron-right"
height={25}
iconStyle={{
marginTop: 2
}}
style={{
paddingHorizontal: DefaultAppStyles.GAP_SMALL
}}
/>
)}
</View>
</View>
{currentAuthMode !== AuthMode.login ? (
<Signup
changeMode={(mode) => setCurrentAuthMode(mode)}

View File

@@ -17,7 +17,6 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useEffect, useState } from "react";
import { TouchableOpacity, View, useWindowDimensions } from "react-native";
@@ -25,9 +24,10 @@ import { SheetManager } from "react-native-actions-sheet";
import { DDS } from "../../services/device-detection";
import { eSendEvent } from "../../services/event-manager";
import Sync from "../../services/sync";
import { useSettingStore } from "../../stores/use-setting-store";
import { useUserStore } from "../../stores/use-user-store";
import { eUserLoggedIn } from "../../utils/events";
import { AppFontSize } from "../../utils/size";
import { SIZE } from "../../utils/size";
import { sleep } from "../../utils/time";
import SheetProvider from "../sheet-provider";
import { Progress } from "../sheets/progress";
@@ -38,8 +38,6 @@ import Paragraph from "../ui/typography/paragraph";
import { hideAuth } from "./common";
import { ForgotPassword } from "./forgot-password";
import { useLogin } from "./use-login";
import { DefaultAppStyles } from "../../utils/styles";
import { Dialog } from "../dialog";
const LoginSteps = {
emailAuth: 1,
@@ -72,8 +70,8 @@ export const Login = ({ changeMode }) => {
}
}, 5000);
});
const deviceMode = useSettingStore((state) => state.deviceMode);
const { width, height } = useWindowDimensions();
const isTablet = width > 600;
useEffect(() => {
async () => {
setStep(LoginSteps.emailAuth);
@@ -90,31 +88,30 @@ export const Login = ({ changeMode }) => {
return (
<>
<ForgotPassword />
<Dialog context="two_factor_verify" />
<SheetProvider context="two_factor_verify" />
<View
style={{
borderRadius: DDS.isTab ? 5 : 0,
backgroundColor: colors.primary.background,
zIndex: 10,
width: "100%",
height: "100%",
alignSelf: "center"
}}
>
<View
style={{
justifyContent: "flex-end",
paddingHorizontal: DefaultAppStyles.GAP,
paddingHorizontal: 20,
backgroundColor: colors.secondary.background,
borderBottomWidth: 0.8,
marginBottom: DefaultAppStyles.GAP_VERTICAL,
marginBottom: 12,
borderBottomColor: colors.primary.border,
alignSelf: isTablet ? "center" : undefined,
borderWidth: isTablet ? 1 : null,
borderColor: isTablet ? colors.primary.border : null,
borderRadius: isTablet ? 20 : null,
marginTop: isTablet ? 50 : null,
width: !isTablet ? null : "70%",
alignSelf: deviceMode !== "mobile" ? "center" : undefined,
borderWidth: deviceMode !== "mobile" ? 1 : null,
borderColor: deviceMode !== "mobile" ? colors.primary.border : null,
borderRadius: deviceMode !== "mobile" ? 20 : null,
marginTop: deviceMode !== "mobile" ? 50 : null,
width: deviceMode === "mobile" ? null : "50%",
minHeight: height * 0.4
}}
>
@@ -145,12 +142,12 @@ export const Login = ({ changeMode }) => {
<Heading
style={{
marginBottom: 25,
marginTop: DefaultAppStyles.GAP_VERTICAL
marginTop: 10
}}
extraBold
size={AppFontSize.xxl}
size={SIZE.xxl}
>
{strings.loginToYourAccount()}
Login to your {"\n"}account
</Heading>
</View>
@@ -165,7 +162,7 @@ export const Login = ({ changeMode }) => {
: "99.9%",
backgroundColor: colors.primary.background,
alignSelf: "center",
paddingHorizontal: DefaultAppStyles.GAP
paddingHorizontal: 20
}}
>
<Input
@@ -181,8 +178,8 @@ export const Login = ({ changeMode }) => {
validationType="email"
autoCorrect={false}
autoCapitalize="none"
errorMessage={strings.emailInvalid()}
placeholder={strings.email()}
errorMessage="Email is invalid"
placeholder="Enter your email"
defaultValue={email.current}
editable={step === LoginSteps.emailAuth && !loading}
onSubmit={() => {
@@ -202,23 +199,23 @@ export const Login = ({ changeMode }) => {
password.current = value;
}}
testID="input.password"
returnKeyLabel={strings.done()}
returnKeyLabel="Done"
returnKeyType="done"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.password()}
placeholder="Password"
marginBottom={0}
editable={!loading}
defaultValue={password.current}
onSubmit={() => login()}
/>
<Button
title={strings.forgotPassword()}
title="Forgot your password?"
style={{
alignSelf: "flex-end",
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
height: 30,
paddingHorizontal: 0
}}
onPress={() => {
@@ -228,7 +225,7 @@ export const Login = ({ changeMode }) => {
textStyle={{
textDecorationLine: "underline"
}}
fontSize={AppFontSize.xs}
fontSize={SIZE.xs}
type="plain"
/>
</>
@@ -246,27 +243,33 @@ export const Login = ({ changeMode }) => {
login();
}}
style={{
width: 250
width: 250,
borderRadius: 100
}}
height={50}
fontSize={SIZE.md}
type="accent"
title={!loading ? strings.continue() : null}
title={!loading ? "Continue" : null}
/>
{step === LoginSteps.passwordAuth && (
<Button
title={strings.cancelLogin()}
title="Cancel logging in"
style={{
alignSelf: "center",
marginTop: DefaultAppStyles.GAP_VERTICAL,
width: 250
height: 30,
marginTop: 10
}}
onPress={() => {
if (loading) return;
setStep(LoginSteps.emailAuth);
setLoading(false);
}}
fontSize={AppFontSize.xs}
type="secondaryAccented"
textStyle={{
textDecorationLine: "underline"
}}
fontSize={SIZE.xs}
type="errorShade"
/>
)}
@@ -279,20 +282,20 @@ export const Login = ({ changeMode }) => {
activeOpacity={0.8}
style={{
alignSelf: "center",
marginTop: DefaultAppStyles.GAP_VERTICAL,
paddingVertical: DefaultAppStyles.GAP_VERTICAL
marginTop: 12,
paddingVertical: 12
}}
>
<Paragraph
size={AppFontSize.xs}
size={SIZE.xs + 1}
color={colors.secondary.paragraph}
>
{strings.dontHaveAccount()}{" "}
Don't have an account?{" "}
<Paragraph
size={AppFontSize.xs}
size={SIZE.xs + 1}
style={{ color: colors.primary.accent }}
>
{strings.signUp()}
Sign up
</Paragraph>
</Paragraph>
</TouchableOpacity>

View File

@@ -34,7 +34,7 @@ import Sync from "../../services/sync";
import { clearAllStores } from "../../stores";
import { useUserStore } from "../../stores/use-user-store";
import { eLoginSessionExpired, eUserLoggedIn } from "../../utils/events";
import { AppFontSize } from "../../utils/size";
import { SIZE } from "../../utils/size";
import { sleep } from "../../utils/time";
import { Dialog } from "../dialog";
import BaseDialog from "../dialog/base-dialog";
@@ -47,9 +47,18 @@ import Input from "../ui/input";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { LoginSteps, useLogin } from "./use-login";
import { strings } from "@notesnook/intl";
import { getObfuscatedEmail } from "../../utils/functions";
import { DefaultAppStyles } from "../../utils/styles";
function getObfuscatedEmail(email) {
if (!email) return "";
const [username, provider] = email.split("@");
if (username.length === 1) return `****@${provider}`;
return email.replace(/(.{1})(.*)(?=@)/, function (gp1, gp2, gp3) {
for (let i = 0; i < gp3.length; i++) {
gp2 += "*";
}
return gp2;
});
}
export const SessionExpired = () => {
const { colors } = useThemeColors();
@@ -158,7 +167,7 @@ export const SessionExpired = () => {
<View
style={{
width: focused ? "100%" : "99.9%",
padding: DefaultAppStyles.GAP,
padding: 12,
justifyContent: "center",
flex: 1,
backgroundColor: colors.primary.background
@@ -183,15 +192,16 @@ export const SessionExpired = () => {
color={colors.error.icon}
size={50}
/>
<Heading size={AppFontSize.xxxl} color={colors.primary.heading}>
{strings.sessionExpired()}
<Heading size={SIZE.xxxl} color={colors.primary.heading}>
Session expired
</Heading>
<Paragraph
style={{
textAlign: "center"
}}
>
{strings.sessionExpiredDesc(getObfuscatedEmail(email.current))}
Your session on this device has expired. Please enter password for{" "}
{getObfuscatedEmail(email.current)} to continue.
</Paragraph>
</View>
@@ -201,46 +211,47 @@ export const SessionExpired = () => {
onChangeText={(value) => {
password.current = value;
}}
returnKeyLabel={strings.done()}
returnKeyLabel="Next"
returnKeyType="next"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.password()}
placeholder="Password"
onSubmit={() => login()}
/>
) : null}
<Button
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL,
marginTop: 10,
width: 250,
borderRadius: 100
}}
loading={loading}
onPress={() => login()}
type="accent"
title={loading ? null : strings.login()}
title={loading ? null : "Login"}
/>
<Button
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL,
marginTop: 10,
width: "100%"
}}
onPress={() => {
presentDialog({
context: "session_expiry",
title: strings.logoutFromDevice(),
paragraph: strings.logoutDesc(),
positiveText: strings.logout(),
title: "Logout",
paragraph:
"Are you sure you want to logout from this device? Any unsynced changes will be lost.",
positiveText: "Logout",
positiveType: "errorShade",
positivePress: logout
});
}}
type="errorShade"
title={loading ? null : strings.logoutFromDevice()}
title={loading ? null : "Logout from this device"}
/>
</View>
<Toast context="local" />

View File

@@ -17,8 +17,6 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useRef, useState } from "react";
import { TouchableOpacity, View, useWindowDimensions } from "react-native";
import { db } from "../../common/database";
@@ -26,17 +24,17 @@ import { DDS } from "../../services/device-detection";
import { ToastManager } from "../../services/event-manager";
import { clearMessage, setEmailVerifyMessage } from "../../services/message";
import PremiumService from "../../services/premium";
import SettingsService from "../../services/settings";
import { useThemeColors } from "@notesnook/theme";
import { useUserStore } from "../../stores/use-user-store";
import { openLinkInBrowser } from "../../utils/functions";
import { AppFontSize } from "../../utils/size";
import { SIZE } from "../../utils/size";
import { sleep } from "../../utils/time";
import { Button } from "../ui/button";
import Input from "../ui/input";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { hideAuth } from "./common";
import { DefaultAppStyles } from "../../utils/styles";
import { useSettingStore } from "../../stores/use-setting-store";
export const Signup = ({ changeMode, trial }) => {
const { colors } = useThemeColors();
@@ -50,13 +48,13 @@ export const Signup = ({ changeMode, trial }) => {
const [loading, setLoading] = useState(false);
const setUser = useUserStore((state) => state.setUser);
const setLastSynced = useUserStore((state) => state.setLastSynced);
const deviceMode = useSettingStore((state) => state.deviceMode);
const { width, height } = useWindowDimensions();
const isTablet = width > 600;
const validateInfo = () => {
if (!password.current || !email.current || !confirmPassword.current) {
ToastManager.show({
heading: strings.allFieldsRequired(),
message: strings.allFieldsRequiredDesc(),
heading: "All fields required",
message: "Fill all the fields and try again",
type: "error",
context: "local"
});
@@ -79,7 +77,6 @@ export const Signup = ({ changeMode, trial }) => {
clearMessage();
setEmailVerifyMessage();
hideAuth();
SettingsService.setProperty("encryptedBackup", true);
await sleep(300);
if (trial) {
PremiumService.sheet(null, null, true);
@@ -89,7 +86,7 @@ export const Signup = ({ changeMode, trial }) => {
} catch (e) {
setLoading(false);
ToastManager.show({
heading: strings.signupFailed(),
heading: "Signup failed",
message: e.message,
type: "error",
context: "local"
@@ -105,24 +102,23 @@ export const Signup = ({ changeMode, trial }) => {
backgroundColor: colors.primary.background,
zIndex: 10,
width: "100%",
height: "100%",
alignSelf: "center"
}}
>
<View
style={{
justifyContent: "flex-end",
paddingHorizontal: DefaultAppStyles.GAP,
paddingHorizontal: 20,
backgroundColor: colors.secondary.background,
marginBottom: 20,
borderBottomWidth: 0.8,
marginBottom: DefaultAppStyles.GAP_VERTICAL,
borderBottomColor: colors.primary.border,
alignSelf: isTablet ? "center" : undefined,
borderWidth: isTablet ? 1 : null,
borderColor: isTablet ? colors.primary.border : null,
borderRadius: isTablet ? 20 : null,
marginTop: isTablet ? 50 : null,
width: !isTablet ? null : "70%",
alignSelf: deviceMode !== "mobile" ? "center" : undefined,
borderWidth: deviceMode !== "mobile" ? 1 : null,
borderColor: deviceMode !== "mobile" ? colors.primary.border : null,
borderRadius: deviceMode !== "mobile" ? 20 : null,
marginTop: deviceMode !== "mobile" ? 50 : null,
width: deviceMode === "mobile" ? null : "50%",
minHeight: height * 0.4
}}
>
@@ -154,18 +150,18 @@ export const Signup = ({ changeMode, trial }) => {
extraBold
style={{
marginBottom: 25,
marginTop: DefaultAppStyles.GAP_VERTICAL
marginTop: 10
}}
size={AppFontSize.xxl}
size={SIZE.xxl}
>
{strings.createYourAccount()}
Create your {"\n"}account
</Heading>
</View>
<View
style={{
width: DDS.isTab ? "50%" : "100%",
paddingHorizontal: DefaultAppStyles.GAP,
paddingHorizontal: 20,
backgroundColor: colors.primary.background,
alignSelf: "center"
}}
@@ -177,14 +173,14 @@ export const Signup = ({ changeMode, trial }) => {
}}
testID="input.email"
onErrorCheck={(e) => setError(e)}
returnKeyLabel={strings.next()}
returnKeyLabel="Next"
returnKeyType="next"
autoComplete="email"
validationType="email"
autoCorrect={false}
autoCapitalize="none"
errorMessage={strings.email()}
placeholder={strings.email()}
errorMessage="Email is invalid"
placeholder="Email"
onSubmit={() => {
passwordInputRef.current?.focus();
}}
@@ -197,14 +193,14 @@ export const Signup = ({ changeMode, trial }) => {
}}
testID="input.password"
onErrorCheck={(e) => setError(e)}
returnKeyLabel={strings.next()}
returnKeyLabel="Next"
returnKeyType="next"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
validationType="password"
autoCorrect={false}
placeholder={strings.password()}
placeholder="Password"
onSubmit={() => {
confirmPasswordInputRef.current?.focus();
}}
@@ -217,7 +213,7 @@ export const Signup = ({ changeMode, trial }) => {
}}
testID="input.confirmPassword"
onErrorCheck={(e) => setError(e)}
returnKeyLabel={strings.done()}
returnKeyLabel="Signup"
returnKeyType="done"
secureTextEntry
autoComplete="password"
@@ -225,7 +221,7 @@ export const Signup = ({ changeMode, trial }) => {
autoCorrect={false}
validationType="confirmPassword"
customValidator={() => password.current}
placeholder={strings.confirmPassword()}
placeholder="Confirm password"
marginBottom={12}
onSubmit={signup}
/>
@@ -234,12 +230,12 @@ export const Signup = ({ changeMode, trial }) => {
style={{
marginBottom: 25
}}
size={AppFontSize.xxs}
size={SIZE.xs}
color={colors.secondary.paragraph}
>
{strings.signupAgreement[0]()}
By signing up, you agree to our{" "}
<Paragraph
size={AppFontSize.xxs}
size={SIZE.xs}
onPress={() => {
openLinkInBrowser("https://notesnook.com/tos", colors);
}}
@@ -248,12 +244,11 @@ export const Signup = ({ changeMode, trial }) => {
}}
color={colors.primary.accent}
>
{" "}
{strings.signupAgreement[1]()}
</Paragraph>{" "}
{strings.signupAgreement[2]()}
Terms of Service{" "}
</Paragraph>
and{" "}
<Paragraph
size={AppFontSize.xxs}
size={SIZE.xs}
onPress={() => {
openLinkInBrowser("https://notesnook.com/privacy", colors);
}}
@@ -262,19 +257,22 @@ export const Signup = ({ changeMode, trial }) => {
}}
color={colors.primary.accent}
>
{" "}
{strings.signupAgreement[3]()}
Privacy Policy.
</Paragraph>{" "}
{strings.signupAgreement[4]()}
You also agree to recieve marketing emails from us which you can
opt-out of from app settings.
</Paragraph>
<Button
title={!loading ? strings.continue() : null}
title={!loading ? "Continue" : null}
type="accent"
loading={loading}
onPress={signup}
fontSize={SIZE.md}
style={{
width: 250
marginRight: 12,
width: 250,
borderRadius: 100
}}
/>
@@ -286,17 +284,17 @@ export const Signup = ({ changeMode, trial }) => {
activeOpacity={0.8}
style={{
alignSelf: "center",
marginTop: DefaultAppStyles.GAP_VERTICAL,
paddingVertical: DefaultAppStyles.GAP_VERTICAL
marginTop: 12,
paddingVertical: 12
}}
>
<Paragraph size={AppFontSize.xs} color={colors.secondary.paragraph}>
{strings.alreadyHaveAccount()}{" "}
<Paragraph size={SIZE.xs + 1} color={colors.secondary.paragraph}>
Already have an account?{" "}
<Paragraph
size={AppFontSize.xs}
size={SIZE.xs + 1}
style={{ color: colors.primary.accent }}
>
{strings.login()}
Login
</Paragraph>
</Paragraph>
</TouchableOpacity>

View File

@@ -17,37 +17,54 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { View } from "react-native";
import { ScrollView } from "react-native-actions-sheet";
import { db } from "../../common/database/index";
import useTimer from "../../hooks/use-timer";
import { eSendEvent, ToastManager } from "../../services/event-manager";
import { eCloseSimpleDialog } from "../../utils/events";
import { AppFontSize } from "../../utils/size";
import {
eSendEvent,
presentSheet,
ToastManager
} from "../../services/event-manager";
import { useThemeColors } from "@notesnook/theme";
import { eCloseSheet } from "../../utils/events";
import { SIZE } from "../../utils/size";
import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import Input from "../ui/input";
import { Pressable } from "../ui/pressable";
import Seperator from "../ui/seperator";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { DefaultAppStyles } from "../../utils/styles";
import { presentDialog } from "../dialog/functions";
import { useCallback } from "react";
import { ScrollView } from "react-native-actions-sheet";
const TwoFactorVerification = ({ onMfaLogin, mfaInfo, onCancel }) => {
const TwoFactorVerification = ({ onMfaLogin, mfaInfo }) => {
const { colors } = useThemeColors();
const code = useRef();
const [currentMethod, setCurrentMethod] = useState({
method: mfaInfo?.primaryMethod,
isPrimary: true
});
const { seconds, start, reset } = useTimer(currentMethod.method);
const { seconds, start } = useTimer(currentMethod.method);
const [loading, setLoading] = useState(false);
const inputRef = useRef();
const [sending, setSending] = useState(false);
const codeHelpText = {
app: "Enter the 6 digit code from your authenticator app to continue logging in",
sms: "Enter the 6 digit code sent to your phone number to continue logging in",
email: "Enter the 6 digit code sent to your email to continue logging in",
recoveryCode: "Enter the recovery code to continue logging in"
};
const secondaryMethodsText = {
app: "I don't have access to authenticator app",
sms: "I don't have access to my phone",
email: "I don't have access to email",
recoveryCode: "I don't have recovery codes"
};
const onNext = async () => {
if (!code.current || code.current.length < 6) return;
setLoading(true);
@@ -59,7 +76,7 @@ const TwoFactorVerification = ({ onMfaLogin, mfaInfo, onCancel }) => {
},
(result) => {
if (result) {
eSendEvent(eCloseSimpleDialog, "two_factor_verify");
eSendEvent(eCloseSheet, "two_factor_verify");
}
setLoading(false);
}
@@ -77,22 +94,22 @@ const TwoFactorVerification = ({ onMfaLogin, mfaInfo, onCancel }) => {
const methods = [
{
id: "sms",
title: strings.sendCodeSms(),
title: "Send code via SMS",
icon: "message-plus-outline"
},
{
id: "email",
title: strings.sendCodeEmail(),
title: "Send code via email",
icon: "email-outline"
},
{
id: "app",
title: strings.authAppCode(),
title: "Enter code from authenticator app",
icon: "cellphone-key"
},
{
id: "recoveryCode",
title: strings.recoveryCode(),
title: "I have a recovery code",
icon: "key"
}
];
@@ -130,18 +147,11 @@ const TwoFactorVerification = ({ onMfaLogin, mfaInfo, onCancel }) => {
<ScrollView
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
style={{
width: "100%",
height: "100%",
backgroundColor: colors.primary.background,
paddingTop: 60
}}
>
<View
style={{
alignItems: "center",
paddingHorizontal: currentMethod.method ? 12 : 0,
gap: 12
paddingHorizontal: currentMethod.method ? 12 : 0
}}
>
<IconButton
@@ -158,7 +168,9 @@ const TwoFactorVerification = ({ onMfaLogin, mfaInfo, onCancel }) => {
textAlign: "center"
}}
>
{currentMethod.method ? strings["2fa"]() : strings.select2faMethod()}
{currentMethod.method
? "Two factor authentication"
: "Select methods for two-factor authentication"}
</Heading>
<Paragraph
style={{
@@ -166,10 +178,12 @@ const TwoFactorVerification = ({ onMfaLogin, mfaInfo, onCancel }) => {
textAlign: "center"
}}
>
{strings["2faCodeHelpText"][currentMethod.method]?.() ||
strings.select2faCodeHelpText()}
{codeHelpText[currentMethod.method] ||
"Select how you would like to recieve the code"}
</Paragraph>
<Seperator />
{currentMethod.method === "sms" || currentMethod.method === "email" ? (
<Button
onPress={onSendCode}
@@ -177,17 +191,15 @@ const TwoFactorVerification = ({ onMfaLogin, mfaInfo, onCancel }) => {
title={
sending
? ""
: `${
seconds
? strings.resend2faCode(seconds)
: strings.sendCode()
}`
: `${seconds ? `Resend code in (${seconds})` : "Send code"}`
}
loading={sending}
height={30}
/>
) : null}
<Seperator />
{currentMethod.method ? (
<>
<Input
@@ -208,9 +220,9 @@ const TwoFactorVerification = ({ onMfaLogin, mfaInfo, onCancel }) => {
}}
onSubmitEditing={onNext}
caretHidden
height={60}
inputStyle={{
fontSize: AppFontSize.lg,
fontSize: SIZE.lg,
height: 60,
textAlign: "center",
letterSpacing: 10,
width: 250
@@ -220,36 +232,27 @@ const TwoFactorVerification = ({ onMfaLogin, mfaInfo, onCancel }) => {
}
enablesReturnKeyAutomatically
containerStyle={{
height: 60,
borderWidth: 0,
width: undefined,
minWidth: "50%"
}}
wrapperStyle={{
height: 60
}}
/>
<Seperator />
<Button
title={loading ? null : strings.next()}
title={loading ? null : "Next"}
type="accent"
width={250}
loading={loading}
onPress={onNext}
/>
<Button
title={strings.cancel()}
type="secondaryAccented"
onPress={() => {
reset();
onCancel();
style={{
borderRadius: 100,
marginBottom: 10
}}
width={250}
/>
<Button
title={strings["2faCodeSecondaryMethodText"][
currentMethod.method
]()}
title={secondaryMethodsText[currentMethod.method]}
type="plain"
onPress={onRequestSecondaryMethod}
height={30}
@@ -267,8 +270,8 @@ const TwoFactorVerification = ({ onMfaLogin, mfaInfo, onCancel }) => {
});
}}
style={{
paddingHorizontal: DefaultAppStyles.GAP,
paddingVertical: DefaultAppStyles.GAP_VERTICAL,
paddingHorizontal: 12,
paddingVertical: 12,
marginTop: 0,
flexDirection: "row",
borderRadius: 0,
@@ -280,6 +283,8 @@ const TwoFactorVerification = ({ onMfaLogin, mfaInfo, onCancel }) => {
<IconButton
type="secondaryAccented"
style={{
width: 40,
height: 40,
marginRight: 10
}}
size={15}
@@ -291,7 +296,7 @@ const TwoFactorVerification = ({ onMfaLogin, mfaInfo, onCancel }) => {
flexShrink: 1
}}
>
<Paragraph size={AppFontSize.md}>{item.title}</Paragraph>
<Paragraph size={SIZE.md}>{item.title}</Paragraph>
</View>
</Pressable>
))}
@@ -302,19 +307,16 @@ const TwoFactorVerification = ({ onMfaLogin, mfaInfo, onCancel }) => {
);
};
TwoFactorVerification.present = (onMfaLogin, data, onCancel, context) => {
presentDialog({
TwoFactorVerification.present = (onMfaLogin, data, context) => {
presentSheet({
component: () => (
<TwoFactorVerification
onMfaLogin={onMfaLogin}
mfaInfo={data}
onCancel={onCancel}
/>
<TwoFactorVerification onMfaLogin={onMfaLogin} mfaInfo={data} />
),
context: context || "two_factor_verify",
disableClosing: true,
transparent: false,
statusBarTranslucent: true
onClose: () => {
onMfaLogin();
},
disableClosing: true
});
};

View File

@@ -24,9 +24,8 @@ import { clearMessage } from "../../services/message";
import PremiumService from "../../services/premium";
import SettingsService from "../../services/settings";
import { useUserStore } from "../../stores/use-user-store";
import { eCloseSimpleDialog } from "../../utils/events";
import { eCloseSheet } from "../../utils/events";
import TwoFactorVerification from "./two-factor";
import { strings } from "@notesnook/intl";
export const LoginSteps = {
emailAuth: 1,
@@ -50,8 +49,8 @@ export const useLogin = (onFinishLogin, sessionExpired = false) => {
(!email.current && step === LoginSteps.emailAuth)
) {
ToastManager.show({
heading: strings.allFieldsRequired(),
message: strings.allFieldsRequiredDesc(),
heading: "All fields required",
message: "Fill all the fields and try again",
type: "error",
context: "local"
});
@@ -70,43 +69,35 @@ export const useLogin = (onFinishLogin, sessionExpired = false) => {
switch (step) {
case LoginSteps.emailAuth: {
const mfaInfo = await db.user.authenticateEmail(email.current);
console.log("email auth", mfaInfo);
if (mfaInfo) {
TwoFactorVerification.present(
async (mfa, callback) => {
try {
const success = await db.user.authenticateMultiFactorCode(
mfa.code,
mfa.method
);
TwoFactorVerification.present(async (mfa, callback) => {
try {
const success = await db.user.authenticateMultiFactorCode(
mfa.code,
mfa.method
);
if (success) {
setStep(LoginSteps.passwordAuth);
setLoading(false);
setTimeout(() => {
passwordInputRef.current?.focus();
}, 500);
callback && callback(true);
}
callback && callback(false);
} catch (e) {
callback && callback(false);
if (e.message === "invalid_grant") {
eSendEvent(eCloseSimpleDialog, "two_factor_verify");
setLoading(false);
setStep(LoginSteps.emailAuth);
}
if (success) {
setStep(LoginSteps.passwordAuth);
setLoading(false);
setTimeout(() => {
passwordInputRef.current?.focus();
}, 1);
callback && callback(true);
}
callback && callback(false);
} catch (e) {
callback && callback(false);
if (e.message === "invalid_grant") {
eSendEvent(eCloseSheet, "two_factor_verify");
setLoading(false);
setStep(LoginSteps.emailAuth);
}
},
mfaInfo,
() => {
eSendEvent(eCloseSimpleDialog, "two_factor_verify");
setLoading(false);
setStep(LoginSteps.emailAuth);
}
);
}, mfaInfo);
} else {
finishWithError(new Error(strings.unableToSend2faCode()));
finishWithError(new Error("Unable to send 2FA code"));
}
break;
}
@@ -131,7 +122,7 @@ export const useLogin = (onFinishLogin, sessionExpired = false) => {
if (e.message === "invalid_grant") setStep(LoginSteps.emailAuth);
setLoading(false);
ToastManager.show({
heading: strings.loginFailed(),
heading: "Login failed",
message: e.message,
type: "error",
context: "local"
@@ -140,13 +131,13 @@ export const useLogin = (onFinishLogin, sessionExpired = false) => {
const finishLogin = async () => {
const user = await db.user.getUser();
if (!user) throw new Error(strings.emailOrPasswordIncorrect());
if (!user) throw new Error("Email or password incorrect!");
PremiumService.setPremiumStatus();
setUser(user);
clearMessage();
ToastManager.show({
heading: strings.loginSuccess(),
message: strings.loginSuccessDesc(),
heading: "Login successful",
message: `Logged in as ${user.email}`,
type: "success",
context: "global"
});

View File

@@ -20,7 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useThemeColors } from "@notesnook/theme";
import { useRoute } from "@react-navigation/native";
import React, { useCallback, useEffect } from "react";
import { Keyboard, TouchableOpacity, View, ViewStyle } from "react-native";
import { Keyboard, View } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
@@ -33,31 +33,23 @@ import { editorState } from "../../screens/editor/tiptap/utils";
import { useSelectionStore } from "../../stores/use-selection-store";
import { useSettingStore } from "../../stores/use-setting-store";
import { getElevationStyle } from "../../utils/elevation";
import { AppFontSize, normalize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { hexToRGBA, RGB_Linear_Shade } from "../../utils/colors";
import { SIZE, normalize } from "../../utils/size";
import NativeTooltip from "../../utils/tooltip";
import { Pressable } from "../ui/pressable";
interface FloatingButtonProps {
title: string;
onPress: () => void;
color?: string;
shouldShow?: boolean;
alwaysVisible?: boolean;
icon?: string;
testID?: string;
position?: "left" | "right";
size?: "small" | "large";
style?: ViewStyle;
}
const FloatingButton = ({
title,
onPress,
color,
alwaysVisible = false,
icon,
testID,
position = "right",
size = "large",
style
alwaysVisible = false
}: FloatingButtonProps) => {
const { colors } = useThemeColors();
const deviceMode = useSettingStore((state) => state.deviceMode);
@@ -119,26 +111,25 @@ const FloatingButton = ({
style={[
{
position: "absolute",
right: position === "right" ? DefaultAppStyles.GAP : undefined,
left: position === "left" ? DefaultAppStyles.GAP : undefined,
right: 12,
bottom: 20,
zIndex: 10,
...style
zIndex: 10
},
animatedStyle
]}
>
<TouchableOpacity
testID={testID || notesnook.buttons.add}
activeOpacity={0.95}
<Pressable
testID={notesnook.buttons.add}
type="accent"
accentColor={color}
style={{
...getElevationStyle(5),
borderRadius: size === "small" ? 15 : 20,
borderTopWidth: 0,
borderBottomWidth: 0,
borderLeftWidth: 0,
borderRightWidth: 0,
backgroundColor: colors.primary.background
borderRadius: 100
}}
onLongPress={(event) => {
if (title) {
NativeTooltip.show(event, title, NativeTooltip.POSITIONS.LEFT);
}
}}
onPress={onPress}
>
@@ -146,29 +137,23 @@ const FloatingButton = ({
style={{
alignItems: "center",
justifyContent: "center",
height: normalize(size === "small" ? 40 : 60),
width: normalize(size === "small" ? 40 : 60),
backgroundColor: color
? RGB_Linear_Shade(0.87, hexToRGBA(color))
: colors.primary.shade,
borderRadius: size === "small" ? 15 : 20
height: normalize(60),
width: normalize(60)
}}
>
<Icon
name={
icon
? icon
: route.name === "Notebooks"
route.name === "Notebooks"
? "notebook-plus"
: route.name === "Trash"
? "delete"
: "plus"
}
color={color || colors.primary.accent}
size={size === "small" ? AppFontSize.xl : AppFontSize.xxxl}
color={colors.primary.accentForeground}
size={SIZE.xxl}
/>
</View>
</TouchableOpacity>
</Pressable>
</Animated.View>
);
};

View File

@@ -23,8 +23,6 @@ import { useMessageStore } from "../../stores/use-message-store";
import { useThemeColors } from "@notesnook/theme";
import { ColorValues } from "../../utils/colors";
import { hexToRGBA } from "../../utils/colors";
import { defaultBorderRadius } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
export const DefaultPlaceholder = ({ color }: { color: string }) => {
const { colors } = useThemeColors();
@@ -42,7 +40,7 @@ export const DefaultPlaceholder = ({ color }: { color: string }) => {
<View
style={{
width: "100%",
paddingHorizontal: DefaultAppStyles.GAP
paddingHorizontal: 12
}}
>
{hasAnnoucements ? (
@@ -53,7 +51,7 @@ export const DefaultPlaceholder = ({ color }: { color: string }) => {
borderRadius: 10,
marginBottom: 20,
backgroundColor: colors.secondary.background,
padding: DefaultAppStyles.GAP
padding: 12
}}
>
<View
@@ -61,7 +59,7 @@ export const DefaultPlaceholder = ({ color }: { color: string }) => {
width: 150,
height: 20,
backgroundColor: colors.primary.hover,
borderRadius: defaultBorderRadius,
borderRadius: 5,
marginBottom: 10
}}
/>
@@ -70,7 +68,7 @@ export const DefaultPlaceholder = ({ color }: { color: string }) => {
width: 250,
height: 14,
backgroundColor: colors.primary.hover,
borderRadius: defaultBorderRadius
borderRadius: 5
}}
/>
@@ -80,7 +78,7 @@ export const DefaultPlaceholder = ({ color }: { color: string }) => {
height: 15,
backgroundColor: shadeColor,
borderRadius: 3,
marginTop: DefaultAppStyles.GAP_VERTICAL
marginTop: 10
}}
/>
</View>
@@ -95,7 +93,7 @@ export const DefaultPlaceholder = ({ color }: { color: string }) => {
marginBottom: 20,
flexDirection: "row",
alignItems: "center",
paddingHorizontal: DefaultAppStyles.GAP
paddingHorizontal: 20
}}
>
<View
@@ -113,7 +111,7 @@ export const DefaultPlaceholder = ({ color }: { color: string }) => {
width: 150,
height: 12,
backgroundColor: colors.secondary.background,
borderRadius: defaultBorderRadius,
borderRadius: 5,
marginBottom: 10
}}
/>
@@ -122,7 +120,7 @@ export const DefaultPlaceholder = ({ color }: { color: string }) => {
width: 250,
height: 16,
backgroundColor: colors.secondary.background,
borderRadius: defaultBorderRadius
borderRadius: 5
}}
/>
</View>
@@ -136,7 +134,7 @@ export const DefaultPlaceholder = ({ color }: { color: string }) => {
backgroundColor: colors.secondary.background,
borderRadius: 10,
marginBottom: 20,
padding: DefaultAppStyles.GAP_SMALL,
padding: 5,
justifyContent: "space-between",
flexDirection: "row",
alignItems: "center"
@@ -181,7 +179,7 @@ export const DefaultPlaceholder = ({ color }: { color: string }) => {
width: 200,
height: 16,
backgroundColor: colors.secondary.background,
borderRadius: defaultBorderRadius
borderRadius: 5
}}
/>
<View
@@ -189,15 +187,15 @@ export const DefaultPlaceholder = ({ color }: { color: string }) => {
width: "85%",
height: 13,
backgroundColor: colors.secondary.background,
borderRadius: defaultBorderRadius,
marginTop: DefaultAppStyles.GAP_VERTICAL
borderRadius: 5,
marginTop: 10
}}
/>
<View
style={{
flexDirection: "row",
marginTop: DefaultAppStyles.GAP_VERTICAL
marginTop: 10
}}
>
<View
@@ -205,7 +203,7 @@ export const DefaultPlaceholder = ({ color }: { color: string }) => {
width: 50,
height: 10,
backgroundColor: colors.secondary.background,
borderRadius: defaultBorderRadius
borderRadius: 5
}}
/>
<View
@@ -213,7 +211,7 @@ export const DefaultPlaceholder = ({ color }: { color: string }) => {
width: 60,
height: 10,
backgroundColor: colors.secondary.background,
borderRadius: defaultBorderRadius,
borderRadius: 5,
marginLeft: 10
}}
/>

Some files were not shown because too many files have changed in this diff Show More