Compare commits

..

1 Commits

Author SHA1 Message Date
01zulfi
26b76f12dc editor: add comment functionality
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2025-11-20 16:18:36 +05:00
959 changed files with 70829 additions and 70291 deletions

View File

@@ -1,30 +0,0 @@
## Description
<!-- Add a detailed summary of what this feature/bugfix does -->
## Type of Change
- [ ] Bug fix
- [ ] Feature
## Visuals
- [ ] Attached relevant screenshots / screen recording / GIF
- [ ] N/A (not a feature or no UI changes)
## Testing
- [ ] Ran all E2E tests
- [ ] Ran all integration tests
- [ ] Added/updated tests for this change (if needed)
- [ ] N/A (tests not needed — explanation provided below)
### If tests were not added, explain why
<!-- explanation -->
## Platform
<!-- Describe which platforms this PR is related to -->
- [ ] Web
- [ ] Mobile
- [ ] Desktop
## Sign-off
- [ ] QA passed
- [ ] UI/UX passed

View File

@@ -55,13 +55,13 @@ jobs:
- name: Detox build
run: |
yarn build:android
ls apps/mobile/android/app/build/outputs/apk
ls apps/mobile/android/app/build/outputs/apk/release
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/.detoxrc.js').devices.emulator.device.avdName")
AVD_NAME=$(node -p "require('./apps/mobile/native/.detoxrc.js').devices.emulator.device.avdName")
echo "AVD_NAME=$AVD_NAME" >> $GITHUB_OUTPUT
- name: AVD cache

View File

@@ -1,135 +0,0 @@
name: Notesnook Android Preview
on:
pull_request_target:
types: [opened, reopened, synchronize]
branches: [master, beta]
paths:
- "apps/mobile/**"
- "packages/**"
- ".github/workflows/android.preview.firebase.yml"
jobs:
authorize:
environment: ${{ github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.full_name != github.repository &&
'external' || 'internal' }}
runs-on: ubuntu-latest
steps:
- run: echo true
build:
needs: authorize
runs-on: ubuntu-22.04
env:
STAGING_BUILD: true
steps:
- name: Checkout
uses: actions/checkout@v2
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Use specific Java version for the builds
uses: joschi/setup-jdk@v2
with:
java-version: "17"
architecture: "x64"
- name: Free Disk Space (Ubuntu)
uses: jlumbroso/free-disk-space@main
with:
# this might remove tools that are actually needed,
# if set to "true" but frees about 6 GB
tool-cache: false
# all of these default to true, but feel free to set to
# "false" if necessary for your workflow
android: false
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: true
- name: Install node modules
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=mobile
- name: Make Gradlew Executable
run: cd apps/mobile/android && chmod +x ./gradlew
- name: Get build number
id: build-number
run: echo "timestamp=$(($(date +%s) - 1774851180 ))" >> $GITHUB_OUTPUT
- name: Check for typescript errors
run: |
npm run tx mobile:build
cd apps/mobile
npx tsc --noEmit
- name: Build arm64-v8a apk
run: |
cd apps/mobile/android
./gradlew assembleRelease -PreactNativeArchitectures=arm64-v8a -PstagingReleaseBuild=true -PprBuildNumber=${{ steps.build-number.outputs.timestamp }}
- name: Get app version
id: package-version
uses: saionaro/extract-package-version@master
with:
path: apps/mobile
- name: Publish to firebase CLI
id: firebase-output
uses: wzieba/Firebase-Distribution-Github-Action@v1
with:
appId: ${{secrets.FIREBASE_APP_ID}}
serviceCredentialsFileContent: ${{ secrets.QA_SERVICE_ACCOUNT }}
groups: testers
file: apps/mobile/android/app/build/outputs/apk/release/app-arm64-v8a-release.apk
releaseNotes: Preview for https://github.com/streetwriters/notesnook/pull/${{github.event.number}}
- name: Post or update PR comment
uses: actions/github-script@v6
env:
preview_url: ${{ steps.firebase-output.outputs.TESTING_URI }}
with:
script: |
const marker = '<!-- android-preview-comment -->';
const prNumber = context.issue.number;
const previewUrl = process.env.preview_url || '';
const body = `${marker}\n**Android App Preview**\n\n${previewUrl || 'Preview URL unavailable — check workflow logs.'}\n\nCommit: ${process.env.GITHUB_SHA}\n`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});
}
- name: Upload sourcemaps
uses: actions/upload-artifact@v4
with:
name: sourcemaps
path: |
apps/mobile/android/app/build/generated/sourcemaps/**/*.map

View File

@@ -5,6 +5,7 @@ on: workflow_dispatch
jobs:
build:
runs-on: ubuntu-22.04
timeout-minutes: 40
env:
CMAKE_C_COMPILER_LAUNCHER: ccache
CMAKE_CXX_COMPILER_LAUNCHER: ccache
@@ -30,23 +31,7 @@ jobs:
npm run bootstrap -- --scope=mobile
- name: Make Gradlew Executable
run: cd apps/mobile/android && chmod +x ./gradlew
- name: Free Disk Space (Ubuntu)
uses: jlumbroso/free-disk-space@main
with:
# this might remove tools that are actually needed,
# if set to "true" but frees about 6 GB
tool-cache: false
# all of these default to true, but feel free to set to
# "false" if necessary for your workflow
android: false
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: true
run: cd apps/mobile/native/android && chmod +x ./gradlew
- name: Install CCache
uses: hendrikmuhs/ccache-action@v1.2.11
@@ -94,9 +79,6 @@ jobs:
cd apps/mobile
npx tsc --noEmit
- name: Remove debug keystore
run: rm -rf apps/mobile/android/app/debug.keystore
- name: Build unsigned app bundle
run: yarn release:android:bundle
@@ -107,13 +89,13 @@ jobs:
id: sign_app
uses: r0adkll/sign-android-release@master
with:
releaseDirectory: apps/mobile/android/app/build/outputs/bundle/release
releaseDirectory: apps/mobile/native/android/app/build/outputs/bundle/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.ALIAS }}
keyStorePassword: ${{ secrets.KEY_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "36.0.0"
BUILD_TOOLS_VERSION: "33.0.0"
- name: Build apks for Github release
run: yarn release:android
@@ -122,17 +104,17 @@ jobs:
id: sign_apk
uses: r0adkll/sign-android-release@master
with:
releaseDirectory: apps/mobile/android/app/build/outputs/apk/release
releaseDirectory: apps/mobile/native/android/app/build/outputs/apk/release
signingKeyBase64: ${{ secrets.PUBLIC_SIGNING_KEY }}
alias: ${{ secrets.PUBLIC_ALIAS }}
keyStorePassword: ${{ secrets.PUBLIC_KEY_PASSWORD }}
keyPassword: ${{ secrets.PUBLIC_KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "36.0.0"
BUILD_TOOLS_VERSION: "33.0.0"
- name: Rename apk files
run: |
cd apps/mobile/android/app/build/outputs/apk/release/
cd apps/mobile/native/android/app/build/outputs/apk/release/
mv app-arm64-v8a-release-unsigned-signed.apk notesnook-arm64-v8a.apk
mv app-armeabi-v7a-release-unsigned-signed.apk notesnook-armeabi-v7a.apk
mv app-x86-release-unsigned-signed.apk notesnook-x86.apk
@@ -140,7 +122,7 @@ jobs:
- name: Get app version
id: package-version
uses: saionaro/extract-package-version@master
uses: martinbeentjes/npm-get-version-action@master
with:
path: apps/mobile
@@ -153,21 +135,21 @@ jobs:
releaseFiles: ${{steps.sign_app.outputs.signedReleaseFile}}
track: beta
status: completed
whatsNewDirectory: apps/mobile/android/releasenotes/
whatsNewDirectory: apps/mobile/native/android/releasenotes/
- name: Create release draft on Github
uses: softprops/action-gh-release@v1
with:
draft: true
tag_name: ${{ steps.package-version.outputs.version}}-beta-android
name: Notesnook Android v${{ steps.package-version.outputs.version}}
tag_name: ${{ steps.package-version.outputs.current-version}}-beta-android
name: Notesnook Android v${{ steps.package-version.outputs.current-version}}
repository: streetwriters/notesnook
token: ${{ secrets.GITHUB_TOKEN }}
files: |
apps/mobile/android/app/build/outputs/apk/release/notesnook-arm64-v8a.apk
apps/mobile/android/app/build/outputs/apk/release/notesnook-armeabi-v7a.apk
apps/mobile/android/app/build/outputs/apk/release/notesnook-x86.apk
apps/mobile/android/app/build/outputs/apk/release/notesnook-x86_64.apk
apps/mobile/native/android/app/build/outputs/apk/release/notesnook-arm64-v8a.apk
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
@@ -175,5 +157,5 @@ jobs:
with:
name: sourcemaps
path: |
apps/mobile/android/app/build/**/*.map
apps/mobile/native/android/app/build/**/*.map
packages/editor-mobile/sourcemaps/*.map

View File

@@ -5,6 +5,7 @@ on: workflow_dispatch
jobs:
build:
runs-on: ubuntu-22.04
timeout-minutes: 40
env:
CMAKE_C_COMPILER_LAUNCHER: ccache
CMAKE_CXX_COMPILER_LAUNCHER: ccache
@@ -24,29 +25,13 @@ jobs:
java-version: "17"
architecture: "x64"
- name: Free Disk Space (Ubuntu)
uses: jlumbroso/free-disk-space@main
with:
# this might remove tools that are actually needed,
# if set to "true" but frees about 6 GB
tool-cache: false
# all of these default to true, but feel free to set to
# "false" if necessary for your workflow
android: false
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: true
- name: Install node modules
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=mobile
- name: Make Gradlew Executable
run: cd apps/mobile/android && chmod +x ./gradlew
run: cd apps/mobile/native/android && chmod +x ./gradlew
- name: Install CCache
uses: hendrikmuhs/ccache-action@v1.2.11
@@ -94,9 +79,6 @@ jobs:
cd apps/mobile
npx tsc --noEmit
- name: Remove debug keystore
run: rm -rf apps/mobile/android/app/debug.keystore
- name: Build unsigned app bundle
run: yarn release:android:bundle
@@ -107,13 +89,13 @@ jobs:
id: sign_app
uses: r0adkll/sign-android-release@master
with:
releaseDirectory: apps/mobile/android/app/build/outputs/bundle/release
releaseDirectory: apps/mobile/native/android/app/build/outputs/bundle/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.ALIAS }}
keyStorePassword: ${{ secrets.KEY_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "36.0.0"
BUILD_TOOLS_VERSION: "33.0.0"
- name: Build apks for Github release
run: yarn release:android
@@ -122,17 +104,17 @@ jobs:
id: sign_apk
uses: r0adkll/sign-android-release@master
with:
releaseDirectory: apps/mobile/android/app/build/outputs/apk/release
releaseDirectory: apps/mobile/native/android/app/build/outputs/apk/release
signingKeyBase64: ${{ secrets.PUBLIC_SIGNING_KEY }}
alias: ${{ secrets.PUBLIC_ALIAS }}
keyStorePassword: ${{ secrets.PUBLIC_KEY_PASSWORD }}
keyPassword: ${{ secrets.PUBLIC_KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "36.0.0"
BUILD_TOOLS_VERSION: "33.0.0"
- name: Rename apk files
run: |
cd apps/mobile/android/app/build/outputs/apk/release/
cd apps/mobile/native/android/app/build/outputs/apk/release/
mv app-arm64-v8a-release-unsigned-signed.apk notesnook-arm64-v8a.apk
mv app-armeabi-v7a-release-unsigned-signed.apk notesnook-armeabi-v7a.apk
mv app-x86-release-unsigned-signed.apk notesnook-x86.apk
@@ -140,7 +122,7 @@ jobs:
- name: Get app version
id: package-version
uses: saionaro/extract-package-version@master
uses: martinbeentjes/npm-get-version-action@master
with:
path: apps/mobile
@@ -153,26 +135,26 @@ jobs:
releaseFiles: ${{steps.sign_app.outputs.signedReleaseFile}}
track: production
status: completed
whatsNewDirectory: apps/mobile/android/releasenotes/
whatsNewDirectory: apps/mobile/native/android/releasenotes/
- name: Create release draft on Github
uses: softprops/action-gh-release@v1
with:
draft: true
tag_name: ${{ steps.package-version.outputs.version}}-android
name: Notesnook Android v${{ steps.package-version.outputs.version}}
tag_name: ${{ steps.package-version.outputs.current-version}}-android
name: Notesnook Android v${{ steps.package-version.outputs.current-version}}
repository: streetwriters/notesnook
token: ${{ secrets.GITHUB_TOKEN }}
files: |
apps/mobile/android/app/build/outputs/apk/release/notesnook-arm64-v8a.apk
apps/mobile/android/app/build/outputs/apk/release/notesnook-armeabi-v7a.apk
apps/mobile/android/app/build/outputs/apk/release/notesnook-x86.apk
apps/mobile/android/app/build/outputs/apk/release/notesnook-x86_64.apk
apps/mobile/native/android/app/build/outputs/apk/release/notesnook-arm64-v8a.apk
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
- name: Upload sourcemaps
uses: actions/upload-artifact@v4
with:
name: sourcemaps
path: |
apps/mobile/android/app/build/**/*.map
apps/mobile/native/android/app/build/**/*.map
packages/editor-mobile/sourcemaps/*.map

View File

@@ -1,301 +0,0 @@
name: Preview @notesnook/desktop
on:
pull_request:
types: [opened, reopened, synchronize]
branches: [master, beta]
paths:
- "apps/desktop/**"
- "apps/web/**"
- "packages/**"
# re-run workflow if workflow file changes
- ".github/workflows/desktop.preview.yml"
jobs:
build:
if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
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
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: Generate desktop build (stable)
run: npm run tx @notesnook/web:build:desktop
- name: Build desktop bundle
working-directory: ./apps/desktop
run: npm run bundle
- name: Archive build artifact
uses: actions/upload-artifact@v4
with:
name: build
path: apps/web/build/**/*
build-macos:
name: Build for macOS
needs: build
runs-on: macos-14
outputs:
macos-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Install setuptools
run: brew install python-setuptools
- name: Setup notarization
run: |
mkdir -p ~/private_keys/
echo '${{ secrets.api_key }}' > ~/private_keys/AuthKey_${{ secrets.api_key_id }}.p8
- name: Collect app metadata
id: app_metadata
working-directory: ./apps/desktop
run: |
echo ::set-output name=apple_app_id::$(cat package.json | jq -r .appAppleId)
echo ::set-output name=app_bundle_id::$(cat package.json | jq -r .build.appId)
echo ::set-output name=app_version::$(cat package.json | jq -r .version)
echo ::set-output name=bundle_version::$(cat package.json | jq -r .build.mac.bundleVersion)
- 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: Install provisioning profile
run: echo "${{ secrets.MAC_PROVISIONING_PROFILE }}" | base64 --decode > embedded.provisionprofile
working-directory: ./apps/desktop
- name: Build dmg
env:
CSC_LINK: ${{ secrets.mac_certs }}
CSC_KEY_PASSWORD: ${{ secrets.mac_certs_password }}
APPLE_API_KEY: ~/private_keys/AuthKey_${{ secrets.api_key_id }}.p8
APPLE_API_KEY_ID: ${{ secrets.api_key_id }}
APPLE_API_ISSUER: ${{ secrets.api_key_issuer_id }}
CSC_FOR_PULL_REQUEST: true
run: |
npm run tx @notesnook/desktop:release
cd apps/desktop
yarn electron-builder --config=electron-builder.config.js --mac dmg --arm64 -p never
- name: Upload dmg artifact
id: artifact-upload-step
uses: actions/upload-artifact@v4
with:
name: macos-build
path: apps/desktop/output/*.dmg
build-linux-x64:
name: Build for Linux x64
needs: build
runs-on: ubuntu-22.04
outputs:
linux-x64-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
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 x64 sqlite-better-trigram
working-directory: ./apps/desktop
- name: Build Electron wrapper
run: npm run tx @notesnook/desktop:release
- name: Build AppImage
run: yarn electron-builder --config=electron-builder.config.js --linux AppImage:x64 -p never
working-directory: ./apps/desktop
- name: Upload AppImage artifact
id: artifact-upload-step
uses: actions/upload-artifact@v4
with:
name: linux-x64-build
path: apps/desktop/output/*.AppImage
build-linux-arm64:
name: Build for Linux arm64
needs: build
runs-on: ubuntu-22.04-arm
outputs:
linux-arm64-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
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
working-directory: ./apps/desktop
- name: Build Electron wrapper
run: npm run tx @notesnook/desktop:release
- name: Build AppImage
run: yarn electron-builder --config=electron-builder.config.js --linux AppImage:arm64 -p never
working-directory: ./apps/desktop
- name: Upload AppImage artifact
id: artifact-upload-step
uses: actions/upload-artifact@v4
with:
name: linux-arm64-build
path: apps/desktop/output/*.AppImage
build-windows:
name: Build for Windows
needs: build
runs-on: windows-latest
outputs:
windows-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
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
npm i --cpu arm64 sqlite3-fts5-html
npm i --cpu x64 sqlite3-fts5-html
working-directory: ./apps/desktop
- name: Build
run: node scripts/execute.mjs @notesnook/desktop:release
- name: Publish
env:
NOTESNOOK_STAGING: true
run: yarn electron-builder --config=electron-builder.config.js --win nsis:x64 --publish never
working-directory: ./apps/desktop
- name: Upload exe artifact
id: artifact-upload-step
uses: actions/upload-artifact@v4
with:
name: windows-build
path: apps/desktop/output/*.exe
post-pr-comment:
name: Post PR comment with preview URLs
needs:
[build, build-macos, build-linux-x64, build-linux-arm64, build-windows]
runs-on: ubuntu-latest
steps:
- name: Post or update PR comment
uses: actions/github-script@v6
env:
macos_artifact_url: ${{ needs.build-macos.outputs.macos-artifact-url }}
linux_x64_artifact_url: ${{ needs.build-linux-x64.outputs.linux-x64-artifact-url }}
linux_arm64_artifact_url: ${{ needs.build-linux-arm64.outputs.linux-arm64-artifact-url }}
windows_artifact_url: ${{ needs.build-windows.outputs.windows-artifact-url }}
with:
script: |
const marker = '<!-- desktop-preview-comment -->';
const prNumber = context.issue.number;
const previewUrl = [
{ platform: 'macOS', url: process.env.macos_artifact_url },
{ platform: 'Linux x64', url: process.env.linux_x64_artifact_url },
{ platform: 'Linux arm64', url: process.env.linux_arm64_artifact_url },
{ platform: 'Windows x64', url: process.env.windows_artifact_url },
].filter(u => u.url).map(u => `- [${u.platform}](${u.url})`).join('\n');
const body = `${marker}\n**Desktop Previews**\n\n${previewUrl || 'Preview URL unavailable — check workflow logs.'}\n\nCommit: ${process.env.GITHUB_SHA}\n`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});
}

View File

@@ -45,7 +45,7 @@ jobs:
test-macos-x64:
name: Test macOS x64
needs: build
runs-on: macos-15-intel
runs-on: macos-13
steps:
- name: Check out Git repository

View File

@@ -1,168 +0,0 @@
name: Notesnook iOS Preview
on:
pull_request_target:
types: [opened, reopened, synchronize]
branches: [master, beta]
paths:
- "apps/mobile/**"
- "packages/**"
- ".github/workflows/ios.preview.firebase.yml"
jobs:
authorize:
environment: ${{ github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.full_name != github.repository &&
'external' || 'internal' }}
runs-on: ubuntu-latest
steps:
- run: echo true
build:
needs: authorize
runs-on: macos-26
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: "26.1.1"
- name: Install node modules
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=mobile
- name: Build packages
run: npm run tx @notesnook/mobile:build
- name: Cache Pods
uses: actions/cache@v3
id: pods-cache
with:
path: apps/mobile/ios/Pods
key: ${{ runner.os }}-pods-${{ hashFiles('apps/mobile/ios/Podfile.lock') }}
- name: Install Pods
run: |
cd apps/mobile/ios
pod install
- name: Check for typescript errors
run: |
npm run tx mobile:build
cd apps/mobile
npx tsc --noEmit
- name: Get marketing version
id: marketing-version
run: echo "version=$(grep "IOS_MARKETING_VERSION" apps/mobile/ios/build-configs/ios-build.staging.xcconfig | awk -F'=' '{print $2}' | xargs)" >> $GITHUB_OUTPUT
- name: Get build number
id: build-number
run: echo "timestamp=$(($(date +%s) - 1774851180 ))" >> $GITHUB_OUTPUT
- name: Make staging xcconfig active
run: |
cd apps/mobile/ios/build-configs
./use-ios-build-config.sh staging --marketing-version ${{steps.marketing-version.outputs.version}} --build-number ${{steps.build-number.outputs.timestamp}}
- name: Build iOS App
uses: ammarahm-ed/ios-build-action@master
with:
bundle-identifier: org.streetwriters.notesnook
scheme: Notesnook
configuration: "Release"
export-options: apps/mobile/ios/ExportOptionsStaging.plist
export-method: "ad-hoc"
project-path: apps/mobile/ios/Notesnook.xcodeproj
workspace-path: apps/mobile/ios/Notesnook.xcworkspace
update-targets: |
Notesnook
Make Note
NotesWidgetExtension
disable-targets: Notesnook-tvOS,Notesnook-tvOSTests,NotesnookTests
code-signing-identity: Apple Distribution
team-id: ${{ secrets.APPLE_TEAM_ID }}
p12-base64: ${{ secrets.APPLE_CERTIFICATE_P12 }}
certificate-password: ${{ secrets.APPLE_CERTIFICATE_P12_PASSWORD }}
app-store-connect-api-key-issuer-id: ${{ secrets.API_KEY_ISSUER_ID }}
app-store-connect-api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
app-store-connect-api-key-base64: ${{ secrets.APPSTORE_CONNECT_API_KEY_BASE64 }}
output-path: Notesnook.ipa
mobileprovision-base64: |
${{ secrets.APPLE_MOBILE_PROVISION_ADHOC_APP }}
${{ secrets.APPLE_MOBILE_PROVISION_ADHOC_SHARE }}
${{ secrets.APPLE_MOBILE_PROVISION_ADHOC_WIDGET }}
- name: Upload IPA artifact
uses: actions/upload-artifact@v4
with:
name: ios-preview-ipa
path: Notesnook.ipa
if-no-files-found: error
retention-days: 1
publish:
runs-on: ubuntu-latest
needs: build
timeout-minutes: 20
steps:
- name: Download IPA artifact
uses: actions/download-artifact@v4
with:
name: ios-preview-ipa
path: .
- name: Publish to firebase CLI
id: firebase-output
uses: wzieba/Firebase-Distribution-Github-Action@v1
with:
appId: ${{secrets.FIREBASE_IOS_APP_ID}}
serviceCredentialsFileContent: ${{ secrets.QA_SERVICE_ACCOUNT }}
groups: testers
file: Notesnook.ipa
releaseNotes: Preview for https://github.com/streetwriters/notesnook/pull/${{github.event.number}}
- name: Post or update PR comment
uses: actions/github-script@v6
env:
preview_url: ${{ steps.firebase-output.outputs.TESTING_URI }}
with:
script: |
const marker = '<!-- ios-preview-comment -->';
const prNumber = context.issue.number;
const previewUrl = process.env.preview_url || '';
const body = `${marker}\n**iOS App Preview**\n\n${previewUrl || 'Preview URL unavailable — check workflow logs.'}\n\nCommit: ${process.env.GITHUB_SHA}\n`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});
}

View File

@@ -4,7 +4,7 @@ on: workflow_dispatch
jobs:
build:
runs-on: macos-26
runs-on: macos-14
timeout-minutes: 60
steps:
@@ -17,7 +17,7 @@ jobs:
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: "26.1.1"
xcode-version: "16.1"
- name: Install node modules
run: |
@@ -30,7 +30,7 @@ jobs:
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.11
with:
key: ${{ runner.os }}-ccache-${{ hashFiles(format('apps/mobile/ios/Podfile.lock')) }}
key: ${{ runner.os }}-ccache-${{ hashFiles(format('apps/mobile/native/ios/Podfile.lock')) }}
max-size: 1500M
restore-keys: |
${{ runner.os }}-ccache-
@@ -55,13 +55,14 @@ jobs:
uses: actions/cache@v3
id: pods-cache
with:
path: apps/mobile/ios/Pods
key: ${{ runner.os }}-pods-${{ hashFiles('apps/mobile/ios/Podfile.lock') }}
path: apps/mobile/native/ios/Pods
key: ${{ runner.os }}-pods-${{ hashFiles('apps/mobile/native/ios/Podfile.lock') }}
- name: Install Pods
run: |
cd apps/mobile/ios
pod install
cd apps/mobile/native/ios
bundle install
RCT_NEW_ARCH_ENABLED=0 bundle exec pod install
- name: Check for typescript errors
run: |
@@ -78,9 +79,9 @@ jobs:
bundle-identifier: org.streetwriters.notesnook
scheme: Notesnook
configuration: "Release"
export-options: apps/mobile/ios/ExportOptions.plist
project-path: apps/mobile/ios/Notesnook.xcodeproj
workspace-path: apps/mobile/ios/Notesnook.xcworkspace
export-options: apps/mobile/native/ios/ExportOptions.plist
project-path: apps/mobile/native/ios/Notesnook.xcodeproj
workspace-path: apps/mobile/native/ios/Notesnook.xcworkspace
update-targets: |
Notesnook
Make Note
@@ -116,5 +117,5 @@ jobs:
name: Notesnook.zip
path: |
Notesnook.ipa
apps/mobile/ios/**/*.map
apps/mobile/native/ios/**/*.map
packages/editor-mobile/sourcemaps/*.map

View File

@@ -1,78 +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/themes-server
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: Collect package metadata
id: package_metadata
working-directory: ./servers/themes
run: |
echo ::set-output name=app_version::$(cat package.json | jq -r .version)
# Setup Buildx
- name: Docker Setup Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64
- 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/themes-server: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/themes-server
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v6
with:
context: .
file: servers/themes/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: streetwriters/themes-server:${{ steps.package_metadata.outputs.app_version }},streetwriters/themes-server:latest
cache-from: streetwriters/themes-server:latest
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
with:
subject-name: index.docker.io/streetwriters/themes-server
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true

40
.github/workflows/web.benchmarks.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
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: 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,80 +0,0 @@
name: Notesnook Web PR Preview
on:
pull_request:
types: [opened, reopened, synchronize]
branches: [master, beta]
paths:
- "apps/web/**"
- "packages/**"
# re-run workflow if workflow file changes
- ".github/workflows/web.preview.yml"
jobs:
build-and-deploy:
if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ubuntu-latest
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Install dependencies
run: npm ci
- name: Build web
run: npm run build:web
- name: Deploy to Cloudflare Pages
id: deploy
working-directory: ./apps/web
run: |
set -euo pipefail
BRANCH=pr-${{ github.event.number }}-$(echo "${{ github.sha }}" | cut -c1-7)
echo "Deploying branch: $BRANCH"
DEPLOY_OUT=$(npx --yes wrangler pages deploy --project-name=notesnook-app --branch="$BRANCH" ./build 2>&1) || { echo "$DEPLOY_OUT"; exit 1; }
echo "$DEPLOY_OUT"
PREVIEW_URL=$(printf "%s" "$DEPLOY_OUT" | grep -Eo 'https?://[^ ]+' | head -1 || true)
if [ -z "$PREVIEW_URL" ]; then
echo "WARNING: could not parse preview URL from wrangler output"
fi
echo "preview_url=$PREVIEW_URL" >> $GITHUB_ENV
- name: Post or update PR comment
uses: actions/github-script@v6
env:
preview_url: ${{ env.preview_url }}
with:
script: |
const marker = '<!-- pages-preview-comment -->';
const prNumber = context.issue.number;
const previewUrl = process.env.preview_url || '';
const body = `${marker}\n**Cloudflare Pages Preview**\n\n${previewUrl || 'Preview URL unavailable — check workflow logs.'}\n\nCommit: ${process.env.GITHUB_SHA}\n`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});
}

2
.npmrc
View File

@@ -1,2 +0,0 @@
min-release-age=7 # days
# ignore-scripts=true

View File

@@ -11,5 +11,5 @@
"cache": true,
"cacheStrategy": "content"
},
"git.rebaseWhenSync": false
"git.rebaseWhenSync": true
}

View File

@@ -198,9 +198,6 @@ module.exports = {
}
}
},
toolsets: {
appimage: "1.0.2"
},
snap: {
autoStart: false,
confinement: "strict",

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
"name": "@notesnook/desktop",
"productName": "Notesnook",
"description": "Your private note taking space",
"version": "3.3.17",
"version": "3.3.5",
"appAppleId": "1544027013",
"private": true,
"main": "./dist/cjs/index.js",
@@ -36,8 +36,8 @@
"electron-trpc": "0.7.1",
"electron-updater": "^6.6.2",
"icojs": "^0.19.5",
"sqlite-better-trigram": "0.0.5",
"sqlite3-fts5-html": "^0.0.6",
"sqlite-better-trigram": "0.0.3",
"sqlite3-fts5-html": "^0.0.4",
"typed-emitter": "^2.1.0",
"yargs": "^17.7.2",
"zod": "3.24.3"
@@ -48,7 +48,7 @@
"@types/yargs": "^17.0.33",
"chokidar": "^4.0.3",
"electron": "^37.0.0",
"electron-builder": "^26.8.1",
"electron-builder": "^26.0.12",
"esbuild": "0.21.5",
"node-abi": "^4.5.0",
"node-gyp-build": "^4.8.4",

View File

@@ -27,7 +27,6 @@ import crypto from "crypto";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const root = path.join(__dirname, "..");
const RUNNING_PROCESSES = [];
const RESTARTABLE_PROCESSES = [];
let lastBundleHash = null;
@@ -37,7 +36,7 @@ const ENV = {
FORCE_COLOR: "false",
COLOR: "0"
};
process.chdir(root);
process.chdir(path.join(__dirname, ".."));
await onChange(true);
@@ -56,16 +55,13 @@ async function onChange(first) {
if (first) {
await fs.rm("./build/", { force: true, recursive: true });
await exec(
"npm run postinstall --verbose",
path.join(root, "node_modules", "electron")
);
await exec("npm rebuild electron --verbose --foreground-scripts");
await exec("yarn electron-builder install-app-deps", root);
await exec("yarn electron-builder install-app-deps");
}
await exec(`yarn run bundle`, root);
await exec(`yarn run build`, root);
await exec(`yarn run bundle`);
await exec(`yarn run build`);
if (await isBundleSame()) {
console.log("Bundle is same. Doing nothing.");
@@ -115,7 +111,7 @@ function spawnAndWaitUntil(cmd, cwd, predicate) {
async function exec(cmd, cwd) {
try {
console.log(">", cmd, cwd);
console.log(">", cmd);
return execSync(cmd, {
env: ENV,

View File

@@ -178,9 +178,7 @@ export const osIntegrationRouter = t.router({
})
});
notification.show();
if (input.urgency === "critical" && process.platform !== "linux") {
// due to an Electron bug in versions below 40.x, shell.beep() causes a segfault on Linux.
// TODO: Remove when migrating to Electron 40.x or newer.
if (input.urgency === "critical") {
shell.beep();
}
@@ -254,7 +252,6 @@ export const osIntegrationRouter = t.router({
if (menuItem) menu.append(menuItem);
}
if (menu.items.length > 0) menu.popup();
menu.on("menu-will-close", () => emit.next([]));
return () => {
menu.removeAllListeners();
menu.closePopup();

View File

@@ -113,8 +113,7 @@ export class SQLite {
};
}
} catch (e) {
if (e instanceof Error)
throw rewriteError(e, `${e.message} (query: ${sql})`);
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
@@ -213,11 +212,3 @@ function getExtensionPath(extensionName: string, entryPoint: string) {
}
return loadablePath;
}
function rewriteError(e: Error, message: string) {
const error = new Error(message);
error.stack = e.stack;
error.name = e.name;
error.cause = e.cause;
return error;
}

View File

@@ -34,17 +34,6 @@ export const windowRouter = t.router({
}),
maximized: t.procedure.query(() => globalThis.window?.isMaximized()),
fullscreen: t.procedure.query(() => globalThis.window?.isFullScreen()),
onClose: t.procedure.subscription(() => {
return observable<void>((emit) => {
function listener() {
emit.next();
}
globalThis.window?.addListener("close", listener);
return () => {
globalThis.window?.removeListener("close", listener);
};
});
}),
onWindowStateChanged: t.procedure.subscription(() => {
return observable<{ maximized: boolean; fullscreen: boolean }>((emit) => {
function listener() {

View File

@@ -52,9 +52,6 @@ locale.then(({ default: locale }) => {
});
setI18nGlobal(i18n);
const appHostnames = isDevelopment()
? ["localhost", "127.0.0.1"]
: ["app.notesnook.com"];
// only run a single instance
if (!MAC_APP_STORE && !app.requestSingleInstanceLock()) {
console.log("Another instance is already running!");
@@ -176,19 +173,6 @@ async function createWindow() {
return { action: "deny" };
});
mainWindow.webContents.on("will-navigate", (event, url) => {
try {
const parsedUrl = new URL(url);
if (!appHostnames.includes(parsedUrl.hostname)) {
event.preventDefault();
shell.openExternal(url);
}
} catch (e) {
console.error("will-navigate: failed to parse URL", url, e);
event.preventDefault();
}
});
nativeTheme.on("updated", () => {
setupTray();
setupJumplist();

View File

@@ -39,7 +39,7 @@ const LINUX_AUTOSTART_DIRECTORY_PATH = path.join(
"autostart"
);
const HIDDEN_ARG = "--hidden";
const STARTUP_ARGS = ["--hidden"];
export class AutoLaunch {
static enable(hidden: boolean) {
@@ -54,13 +54,13 @@ export class AutoLaunch {
);
} else {
const loginItemSettings = app.getLoginItemSettings({
args: hidden ? [HIDDEN_ARG] : undefined
args: STARTUP_ARGS
});
if (loginItemSettings.openAtLogin) return;
app.setLoginItemSettings({
openAtLogin: true,
openAsHidden: hidden,
args: hidden ? [HIDDEN_ARG] : undefined
args: STARTUP_ARGS
});
}
}

View File

@@ -37,11 +37,7 @@ async function configureAutoUpdater() {
config.releaseTrack === "stable" &&
autoUpdater.currentVersion.prerelease.length > 0;
autoUpdater.allowPrerelease = false;
// Do NOT auto-install on quit. On Windows, if the system shuts down while
// the NSIS installer is running, it first removes all old files and then
// gets killed before copying new ones — leaving an empty install directory.
// Updates should only be installed when the user explicitly triggers it.
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.disableWebInstaller = true;
}

View File

@@ -62,17 +62,7 @@ function registerProtocol() {
headers: { "Content-Type": extensionToMimeType[fileExtension] }
});
} else {
const requestHeaders = new Headers(request.headers);
if (!requestHeaders.has("referer"))
requestHeaders.set("referer", PROTOCOL_URL);
return await net.fetch(
new Request(request, {
headers: requestHeaders
}),
{
bypassCustomProtocolHandlers: true
}
);
return net.fetch(request, { bypassCustomProtocolHandlers: true });
}
});
console.info(`${SCHEME} protocol inteception "successful"`);

View File

@@ -1,92 +0,0 @@
/** @type {Detox.DetoxConfig} */
module.exports = {
testRunner: {
args: {
$0: "jest",
config: "e2e/jest.config.js"
},
jest: {
setupTimeout: 120000
}
},
apps: {
"ios.debug": {
type: "ios.app",
binaryPath:
"ios/build/Build/Products/Debug-iphonesimulator/Notesnook.app",
build:
"xcodebuild -workspace ios/Notesnook.xcworkspace -scheme YOUR_APP -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build"
},
"ios.release": {
type: "ios.app",
binaryPath:
"ios/build/Build/Products/Release-iphonesimulator/Notesnook.app",
build:
"xcodebuild -workspace ios/Notesnook.xcworkspace -scheme YOUR_APP -configuration Release -sdk iphonesimulator -derivedDataPath ios/build"
},
"android.debug": {
type: "android.apk",
binaryPath: "android/app/build/outputs/apk/debug/app-arm64-v8a-debug.apk",
testBinaryPath:
"android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk",
build:
"cd android ; ENVFILE=.env.test ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd ..",
reversePorts: [8081]
},
"android.release": {
type: "android.apk",
binaryPath:
"android/app/build/outputs/apk/release/app-arm64-v8a-release.apk",
testBinaryPath:
"android/app/build/outputs/apk/androidTest/release/app-release-androidTest.apk",
build:
"cd android ; ENVFILE=.env.test ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release ; cd .."
}
},
devices: {
simulator: {
type: "ios.simulator",
device: {
type: "iPhone 17 Pro Max"
}
},
attached: {
type: "android.attached",
device: {
adbName: ".*"
}
},
emulator: {
type: "android.emulator",
device: {
avdName: "Pixel_5_API_36"
}
}
},
configurations: {
"ios.sim.debug": {
device: "simulator",
app: "ios.debug"
},
"ios.sim.release": {
device: "simulator",
app: "ios.release"
},
"android.att.debug": {
device: "attached",
app: "android.debug"
},
"android.att.release": {
device: "attached",
app: "android.release"
},
"android.emu.debug": {
device: "emulator",
app: "android.debug"
},
"android.emu.release": {
device: "emulator",
app: "android.release"
}
}
};

View File

@@ -4,7 +4,7 @@ artifacts/
#
.DS_Store
android/app/src/main/assets/
native/android/app/src/main/assets/
*Issues.md
build_cache/
#
@@ -16,12 +16,11 @@ build_cache/
.cxx/
*.keystore
!debug.keystore
.kotlin/
# Xcode
#
ios/Pods
ios/DerivedData
native/ios/Pods
native/ios/DerivedData
build/
*.pbxuser
!default.pbxuser
@@ -40,7 +39,7 @@ DerivedData
*.xcuserstate
*.hprof
**/.xcode.env.local
cache
native/cache
# Android/IntelliJ
#
rn-build-deps/
@@ -58,7 +57,7 @@ yarn-error.log
# BUCK
buck-out/
\.buckd/
*.keystore
# fastlane
#
@@ -70,7 +69,7 @@ buck-out/
*/fastlane/report.xml
*/fastlane/Preview.html
*/fastlane/screenshots
vendor
native/vendor
# Bundle artifact
*.jsbundle

View File

@@ -3,8 +3,10 @@
</p>
<h1 align="center">Notesnook Mobile</h1>
<h3 align="center">The mobile app is built with React Native for both iOS and Android.</h3>
<p align="center"><a href="#build-instructions">Build instructions</a> | <a href="#developer-guide">Developer guide</a> | <a href="#running-e2e-tests-detox">E2E tests</a></p>
<h3 align="center">The mobile app is built using React Native, Typescript & Javascript for both iOS & Android.</h3>
<p align="center">
<a href="#developer-guide">Developer guide</a> | <a href="#build-instructions">How to build?</a>
</p>
<p align="center">
<a href="https://play.google.com/store/apps/details?id=com.streetwriters.notesnook">
@@ -23,21 +25,21 @@
Requirements:
1. [Node.js](https://nodejs.org/en/download/) 20+ (the repo is pinned to Node `22.20.0` via Volta)
1. [Node.js](https://nodejs.org/en/download/)
2. [git](https://git-scm.com/downloads)
3. `npm`
4. [React Native environment setup](https://reactnative.dev/docs/set-up-your-environment)
3. NPM (not yarn or pnpm)
4. [React Native](https://reactnative.dev/docs/environment-setup)
To run the app locally, first complete React Native native tooling setup:
To run the app locally, you will need to setup React Native on your system:
1. Open [React Native environment setup](https://reactnative.dev/docs/set-up-your-environment)
1. Open the official [environment setup guide here](https://reactnative.dev/docs/environment-setup)
2. Select `React Native CLI Quickstart`
3. Select your OS and target platform(s): iOS and/or Android
3. Select your OS & the platform to run the app on (iOS or Android)
4. Follow the steps listed.
> Expo is not used in this project.
> Please keep in mind that **Expo is not supported**.
Clone the monorepo:
Once you have completed the setup, the first step is to `clone` the monorepo:
```bash
git clone https://github.com/streetwriters/notesnook.git
@@ -46,27 +48,26 @@ git clone https://github.com/streetwriters/notesnook.git
cd notesnook
```
Install dependencies and bootstrap the mobile workspace:
Once you are inside the `./notesnook` directory, run the preparation step:
```bash
# this might take a while to complete
npm install
npm run bootstrap -- --scope=mobile
```
### Running the app on Android
[Set up an Android emulator from Android Studio](https://developer.android.com/studio/run/managing-avds) (or connect a physical device), then run:
[Setup an Android emulator from Android Studio](https://developer.android.com/studio/run/managing-avds) if you haven't already, and then run the following command to start the app in the Emulator:
```bash
npm run start:android
```
If you are using a physical device, enable [USB debugging](https://developer.android.com/studio/debug/dev-options).
If you want to run the app on your phone, make sure to [enable USB debugging](https://developer.android.com/studio/debug/dev-options).
### Running the app on iOS
Install CocoaPods dependencies first, then run the iOS app:
To run the app on iOS:
```bash
# this might take a while to complete
@@ -75,87 +76,90 @@ npm run prepare:ios
npm run start:ios
```
### Useful development commands
```bash
# start Metro only
npm run start:metro
# start Re.Pack bundler
npm run start:repack
```
## Developer guide
> The mobile app is a mixed TypeScript/JavaScript codebase.
> This project is in a transition state between Javascript & Typescript. We are gradually porting everything over to Typescript, so if you can help with that, it'd be great!
### The tech stack
We try to keep the stack as lean as possible:
1. React Native `0.82`
2. React `19`
3. TypeScript + JavaScript
4. Zustand (state management)
5. Detox (end-to-end testing)
6. libsodium (encryption)
1. React Native
2. Typescript/Javascript
3. Zustand: State management
4. Detox: Runs all our e2e tests
5. React Native MMKV: Database & persistence
6. libsodium: Encryption
### Project structure
Top-level directories in `apps/mobile/`:
The app codebase is distributed over two primary directories. `native/` and `app/`.
- `app/`: Main React Native app source (`components`, `common`, `hooks`, `navigation`, `screens`, `services`, `stores`, `utils`, etc.)
- `android/`: Android native project
- `ios/`: iOS native project
- `e2e/`: Detox test suite and config
- `patches/`: `patch-package` patches
- `scripts/`: Mobile-specific scripts
- `native/`: Includes `android/` and `ios/` folders and everything related to react native core functionality like bundling, development, and packaging. Any react-native dependency with native code, i.e., android & ios folders, is installed here.
## Running E2E tests (Detox)
- `app/`: Includes all the app code other than the native part. All JS-only dependencies are installed here.
- `components/`: Each component serves a specific purpose in the app UI. For example, the `Paragraph` component is used to render paragraphs in the app, and a `Header` component is used to render a `header` on all screens.
- `common/`: Features that are integral to the app's functionality. For example, the notesnook core is initialized here.
- `hooks/`: Hooks for different app logic
- `navigation/`: Includes app navigation-specific code. Here the app navigation, editor & side menu are rendered side by side in fluid tabs.
- `screens`: Navigator screens.
- `services`: Parts of code that do a specific function. For example, the `sync` service runs Sync from anywhere in the app.
- `stores`: We use `zustand` for global state management in the app. Multiple stores provide the state for different parts of the app.
- `utils`: General purpose stuff such as constant values, utility functions, etc.
Detox device defaults in this repo:
There are several other folders at the root:
- Android emulator: `Pixel_5_API_36`
- iOS simulator: `iPhone 17 Pro Max`
- `share/`: Code for the iOS Share Extension and Android widget.
- `e2e/`: Detox End to end tests
- `patches/`: Patches for various react native dependencies.
### Running the tests
When you are done making the required changes, you must run the tests to ensure you didn't break anything. We use Detox as the testing framework & the tests can be started as follows:
### Android
Build and run Android Detox tests:
To run the tests on Android, you will need to create an emulator device on your system:
```bash
npm run build:android
npm run test:android
```
$ANDROID_HOME/tools/bin/avdmanager create avd -n Pixel_5_API_31 -d pixel --package "system-images;android-31;default;x86_64"
```
For debug configuration:
If you face problems, follow the detailed guide in [Detox documentation](https://wix.github.io/Detox/docs/introduction/android-dev-env). Keep the emulator name set to `Pixel_5_API_31`.
```bash
npm run build:android:debug
npm run start:metro
npm run test:android:debug
Once you have created an emulator device, build the Android apks:
```
npm run build:android
```
Finally, run the tests:
```
npm run test:android
```
### iOS
Build and run iOS Detox tests:
To run e2e tests on the iOS simulator, you must be on a Mac with XCode installed.
First, install [AppleSimulatorUtils](https://github.com/wix/AppleSimulatorUtils):
```bash
npm run build:ios
npm run test:ios
```
If simulator tooling is missing, install [AppleSimulatorUtils](https://github.com/wix/AppleSimulatorUtils):
```bash
brew tap wix/brew
brew install applesimutils
```
## Release commands
Now build the iOS app for testing:
Android release helpers:
```bash
npm run release:android
npm run release:android:bundle
```
npm run build:ios
```
Finally, run the tests:
```
npm run test:ios
```
All tests on iOS are configured to run on `iPhone 8` simulator.

View File

@@ -1,28 +0,0 @@
package com.streetwriters.notesnook
import android.app.Application
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactHost
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import org.wonday.orientation.OrientationActivityLifecycle;
class MainApplication : Application(), ReactApplication {
override val reactHost: ReactHost by lazy {
getDefaultReactHost(
context = applicationContext,
packageList =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
add(NNativeModulePackage());
},
)
}
override fun onCreate() {
super.onCreate()
registerActivityLifecycleCallbacks(OrientationActivityLifecycle.getInstance());
loadReactNative(this)
}
}

View File

@@ -1,450 +0,0 @@
package com.streetwriters.notesnook;
import android.app.Activity;
import android.appwidget.AppWidgetManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ShortcutInfo;
import android.content.pm.ShortcutManager;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.RectF;
import android.graphics.drawable.Icon;
import android.os.Build;
import android.os.Bundle;
import android.provider.Settings;
import android.util.Log;
import android.view.WindowManager;
import android.widget.RemoteViews;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.WritableMap;
import com.google.gson.Gson;
import com.streetwriters.notesnook.datatypes.Note;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
public class RCTNNativeModule extends ReactContextBaseJavaModule {
Intent lastIntent;
ReactContext mContext;
static String IntentType = "com.streetwriters.notesnook.IntentType";
public RCTNNativeModule(ReactApplicationContext reactContext) {
super(reactContext);
mContext = reactContext;
}
@Override
public String getName() {
return "NNativeModule";
}
@ReactMethod
public void setBackgroundColor(final String color) {
try {
getCurrentActivity().getWindow().getDecorView().setBackgroundColor(Color.parseColor(color));
} catch (Exception e) {
}
}
@ReactMethod
public void getActivityName(Promise promise) {
try {
promise.resolve(getCurrentActivity().getClass().getSimpleName());
} catch (Exception e) {
promise.resolve(null);
}
}
@ReactMethod
public void setSecureMode(final boolean mode) {
try {
getCurrentActivity().runOnUiThread(() -> {
try {
if (mode)
getCurrentActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);
else
getCurrentActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE);
} catch (Exception e) {
}
});
} catch (Exception e) {
}
}
@ReactMethod(isBlockingSynchronousMethod = true)
public int getWidgetId() {
return NotePreviewConfigureActivity.appWidgetId;
}
@ReactMethod
public void setString(final String storeName, final String key, final String value) {
SharedPreferences details = getReactApplicationContext().getSharedPreferences(storeName, Context.MODE_PRIVATE);
SharedPreferences.Editor edit = details.edit();
edit.putString(key, value);
edit.apply();
}
@ReactMethod
public void removeString(final String storeName, final String key) {
SharedPreferences details = getReactApplicationContext().getSharedPreferences(storeName, Context.MODE_PRIVATE);
SharedPreferences.Editor edit = details.edit();
edit.remove(key);
edit.apply();
}
@ReactMethod
public void getString(final String storeName, final String key, Promise promise) {
SharedPreferences details = getReactApplicationContext().getSharedPreferences(storeName, Context.MODE_PRIVATE);
String value = details.getString(key, "");
promise.resolve(value.isEmpty() ? null : value);
}
@ReactMethod
public void saveAndFinish() {
NotePreviewConfigureActivity.saveAndFinish(mContext);
}
@ReactMethod(isBlockingSynchronousMethod = true)
public WritableMap getIntent() {
WritableMap map = Arguments.createMap();
if (getCurrentActivity() != null) {
Intent intent = getCurrentActivity().getIntent();
Bundle extras = getCurrentActivity().getIntent().getExtras();
if (extras != null && intent != lastIntent) {
lastIntent = intent;
if (Objects.equals(extras.getString(IntentType), "NewReminder")) {
map.putString(ReminderWidgetProvider.NewReminder, extras.getString(ReminderWidgetProvider.NewReminder));
} else if (Objects.equals(extras.getString(IntentType), "OpenReminder")) {
map.putString(ReminderViewsService.OpenReminderId, extras.getString(ReminderViewsService.OpenReminderId));
} else if (Objects.equals(extras.getString(IntentType), "OpenNote")) {
map.putString(NotePreviewWidget.OpenNoteId, extras.getString(NotePreviewWidget.OpenNoteId));
}
}
}
return map;
}
@ReactMethod
public void cancelAndFinish() {
NotePreviewConfigureActivity.activity.setResult(Activity.RESULT_CANCELED);
NotePreviewConfigureActivity.activity.finish();
}
@ReactMethod
public void getWidgetNotes(Promise promise) {
SharedPreferences pref = getReactApplicationContext().getSharedPreferences("appPreview", Context.MODE_PRIVATE);
Map<String, ?> map = pref.getAll();
WritableArray arr = Arguments.createArray();
for(Map.Entry<String,?> entry : map.entrySet()){
if (entry.getKey().equals("remindersList")) continue;
String value = (String) entry.getValue();
Gson gson = new Gson();
Note note = gson.fromJson(value, Note.class);
arr.pushString(note.getId());
}
promise.resolve(arr);
}
@ReactMethod
public void hasWidgetNote(final String noteId, Promise promise) {
SharedPreferences pref = getReactApplicationContext().getSharedPreferences("appPreview", Context.MODE_PRIVATE);
Map<String, ?> map = pref.getAll();
boolean found = false;
for(Map.Entry<String,?> entry : map.entrySet()){
String value = (String) entry.getValue();
if (value.contains(noteId)) {
found = true;
}
}
promise.resolve(found);
}
@ReactMethod
public void updateWidgetNote(final String noteId, final String data) {
SharedPreferences pref = getReactApplicationContext().getSharedPreferences("appPreview", Context.MODE_PRIVATE);
Map<String, ?> map = pref.getAll();
SharedPreferences.Editor edit = pref.edit();
ArrayList<String> ids = new ArrayList<>();
for(Map.Entry<String,?> entry : map.entrySet()) {
String value = (String) entry.getValue();
if (value.contains(noteId)) {
edit.putString(entry.getKey(), data);
ids.add(entry.getKey());
}
}
edit.apply();
for (String id: ids) {
NotePreviewWidget.updateAppWidget(mContext, AppWidgetManager.getInstance(mContext), Integer.parseInt(id));
}
}
@ReactMethod
public void updateReminderWidget() {
AppWidgetManager wm = AppWidgetManager.getInstance(mContext);
int[] ids = wm.getAppWidgetIds(ComponentName.createRelative(mContext.getPackageName(), ReminderWidgetProvider.class.getName()));
for (int id: ids) {
Log.d("Reminders", "Updating" + id);
RemoteViews views = new RemoteViews(mContext.getPackageName(), R.layout.widget_reminders);
ReminderWidgetProvider.updateAppWidget(mContext, wm, id, views);
wm.notifyAppWidgetViewDataChanged(id, R.id.widget_list_view);
}
}
@ReactMethod(isBlockingSynchronousMethod = true)
public boolean isGestureNavigationEnabled() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
try {
String navBarMode = Settings.Secure.getString(
mContext.getContentResolver(),
"navigation_mode"
);
return "2".equals(navBarMode);
} catch (Exception e) {
return false;
}
} else {
return false;
}
}
@ReactMethod
public void addShortcut(final String id, final String type, final String title, final String description, final String color, Promise promise) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
promise.reject("UNSUPPORTED", "Pinned launcher shortcuts require Android 8.0 or higher");
return;
}
try {
ShortcutManager shortcutManager = mContext.getSystemService(ShortcutManager.class);
if (shortcutManager == null) {
promise.reject("ERROR", "ShortcutManager not available");
return;
}
String uri = "https://app.notesnook.com/open_" + type + "?id=" + id;
Intent intent = new Intent(Intent.ACTION_VIEW, android.net.Uri.parse(uri));
intent.setPackage(mContext.getPackageName());
Icon icon = createLetterIcon(type, title, color);
ShortcutInfo shortcut = new ShortcutInfo.Builder(mContext, id)
.setShortLabel(title)
.setLongLabel(description != null && !description.isEmpty() ? description : title)
.setIcon(icon)
.setCategories(Set.of(type))
.setIntent(intent)
.build();
shortcutManager.requestPinShortcut(shortcut, null);
promise.resolve(true);
} catch (Exception e) {
promise.reject("ERROR", e.getMessage());
}
}
@ReactMethod
public void removeShortcut(final String id, Promise promise) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
promise.reject("UNSUPPORTED", "Pinned launcher shortcuts require Android 8.0 or higher");
return;
}
try {
ShortcutManager shortcutManager = mContext.getSystemService(ShortcutManager.class);
if (shortcutManager == null) {
promise.reject("ERROR", "ShortcutManager not available");
return;
}
shortcutManager.disableShortcuts(java.util.Collections.singletonList(id));
promise.resolve(true);
} catch (Exception e) {
promise.reject("ERROR", e.getMessage());
}
}
@ReactMethod
public void updateShortcut(final String id, final String type, final String title, final String description,final String color, Promise promise) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
promise.reject("UNSUPPORTED", "Pinned launcher shortcuts require Android 8.0 or higher");
return;
}
try {
ShortcutManager shortcutManager = mContext.getSystemService(ShortcutManager.class);
if (shortcutManager == null) {
promise.reject("ERROR", "ShortcutManager not available");
return;
}
// Get existing shortcut to preserve icon and intent
List<ShortcutInfo> shortcuts = shortcutManager.getPinnedShortcuts();
ShortcutInfo existingShortcut = null;
for (ShortcutInfo s : shortcuts) {
if (s.getId().equals(id)) {
existingShortcut = s;
break;
}
}
if (existingShortcut == null) {
return;
}
Icon icon = createLetterIcon(type, title, color);
ShortcutInfo updatedShortcut = new ShortcutInfo.Builder(mContext, id)
.setShortLabel(title)
.setIcon(icon)
.setLongLabel(description != null && !description.isEmpty() ? description : title)
.setIntent(existingShortcut.getIntent())
.build();
shortcutManager.updateShortcuts(java.util.Collections.singletonList(updatedShortcut));
promise.resolve(true);
} catch (Exception e) {
promise.reject("ERROR", e.getMessage());
}
}
@ReactMethod
public void removeAllShortcuts(Promise promise) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
promise.reject("UNSUPPORTED", "Pinned launcher shortcuts require Android 8.0 or higher");
return;
}
try {
ShortcutManager shortcutManager = mContext.getSystemService(ShortcutManager.class);
if (shortcutManager == null) {
promise.reject("ERROR", "ShortcutManager not available");
return;
}
List<ShortcutInfo> pinnedShortcuts = shortcutManager.getPinnedShortcuts();
if (!pinnedShortcuts.isEmpty()) {
List<String> ids = new ArrayList<>();
for (ShortcutInfo shortcut : pinnedShortcuts) {
ids.add(shortcut.getId());
}
shortcutManager.disableShortcuts(ids);
}
promise.resolve(true);
} catch (Exception e) {
promise.reject("ERROR", e.getMessage());
}
}
@ReactMethod
public void getAllShortcuts(Promise promise) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
ShortcutManager shortcutManager = mContext.getSystemService(ShortcutManager.class);
WritableArray shortcuts = Arguments.createArray();
List<ShortcutInfo> infos = shortcutManager.getPinnedShortcuts();
for (ShortcutInfo info: infos) {
WritableMap data = Arguments.createMap();
data.putString("id", info.getId());
if (info.getShortLabel() != null) {
data.putString("title", info.getShortLabel().toString());
}
if (info.getLongLabel() != null) {
data.putString("description", info.getLongLabel().toString());
}
if (!Objects.requireNonNull(info.getCategories()).isEmpty()) {
if (info.getCategories().contains("note")) {
data.putString("type", "note");
} else if (info.getCategories().contains("notebook")) {
data.putString("type", "notebook");
} else if (info.getCategories().contains("tag")) {
data.putString("type", "tag");
} else if (info.getCategories().contains("color")) {
data.putString("type", "color");
}
}
shortcuts.pushMap(data);
}
promise.resolve(shortcuts);
}
private Icon createLetterIcon(String type, String title, String colorCode) {
String letter = type.contains("tag") ? "#" : title != null && !title.isEmpty()
? title.substring(0, 1).toUpperCase()
: "?";
int color = type.equals("color") ? Color.parseColor(colorCode) : getColorForLetter(letter);
if (type.equals("notebook")) return Icon.createWithResource(mContext, R.drawable.ic_notebook);
// Use a larger canvas and fill it completely to avoid white borders from launcher masking.
int iconSize = 256;
Bitmap bitmap = Bitmap.createBitmap(iconSize, iconSize, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
if (type.equals("color")) {
Paint backgroundPaint = new Paint();
backgroundPaint.setColor(color);
backgroundPaint.setAntiAlias(true);
canvas.drawCircle(iconSize / 2, iconSize / 2, iconSize/2, backgroundPaint);
} else {
Paint textPaint = new Paint();
textPaint.setColor(color);
textPaint.setTextSize(130);
textPaint.setAntiAlias(true);
textPaint.setTextAlign(Paint.Align.CENTER);
textPaint.setTypeface(android.graphics.Typeface.create(android.graphics.Typeface.DEFAULT, android.graphics.Typeface.BOLD));
float x = iconSize / 2f;
float y = (iconSize / 2f) - ((textPaint.descent() + textPaint.ascent()) / 2f);
canvas.drawText(letter, x, y, textPaint);
}
return Icon.createWithBitmap(bitmap);
}
private int getColorForLetter(String letter) {
int[] colors = {
0xFF1976D2, // Blue
0xFFD32F2F, // Red
0xFF388E3C, // Green
0xFFF57C00, // Orange
0xFF7B1FA2, // Purple
0xFF0097A7, // Cyan
0xFFC2185B, // Pink
0xFF455A64, // Blue Grey
0xFF6A1B9A, // Deep Purple
0xFF00796B, // Teal
0xFF512DA8, // Indigo
0xFF1565C0 // Dark Blue
};
int hash = Math.abs(letter.hashCode());
return colors[hash % colors.length];
}
}

View File

@@ -1,11 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="#008837"
android:alpha="1">
<path
android:fillColor="@android:color/white"
android:pathData="M240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880ZM240,800L720,800Q720,800 720,800Q720,800 720,800L720,160Q720,160 720,160Q720,160 720,160L640,160L640,440L540,380L440,440L440,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800ZM240,800Q240,800 240,800Q240,800 240,800L240,160Q240,160 240,160Q240,160 240,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800ZM440,440L540,380L640,440L640,440L540,380L440,440Z"/>
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 440 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 807 B

View File

@@ -1,3 +0,0 @@
- Bug fixes and improvements
Thank you for using Notesnook!

View File

@@ -1,11 +0,0 @@
pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") }
plugins { id("com.facebook.react.settings") }
extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() }
rootProject.name = 'Notesnook'
include ':app'
includeBuild('../node_modules/@react-native/gradle-plugin')
include ":react-native-config"
project(':react-native-config').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-config/android')
include ":lazysodium-android"
project(':lazysodium-android').projectDir = new File(rootProject.projectDir, '../node_modules/@ammarahmed/react-native-sodium/android/lazysodium-android/app')

View File

@@ -23,8 +23,8 @@ import {
THEME_COMPATIBILITY_VERSION,
useThemeEngineStore
} from "@notesnook/theme";
import React, { PropsWithChildren, useEffect } from "react";
import { Appearance, I18nManager, Linking, StatusBar } from "react-native";
import React, { useEffect } from "react";
import { Appearance, I18nManager, StatusBar } from "react-native";
import "react-native-gesture-handler";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
@@ -43,7 +43,7 @@ import { changeSystemBarColors, 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";
import { useSettingStore } from "./stores/use-setting-store";
I18nManager.allowRTL(false);
I18nManager.forceRTL(false);
I18nManager.swapLeftAndRightInRTL(false);
@@ -51,28 +51,19 @@ const { appLockEnabled, appLockMode } = SettingsService.get();
if (appLockEnabled || appLockMode !== "none") {
useUserStore.getState().lockApp(true);
}
RNBootSplash.hide({
fade: true
});
Linking.getInitialURL().then((url) => {
useSettingStore.setState({
initialUrl: url
});
});
RNBootSplash.hide();
const App = (props: { configureMode: "note-preview" }) => {
useAppEvents();
//@ts-ignore
globalThis["IS_MAIN_APP_RUNNING"] = true;
useEffect(() => {
SettingsService.onFirstLaunch();
changeSystemBarColors();
SettingsService.setPrivacyScreen(
SettingsService.getProperty("privacyScreen")
);
SettingsService.onFirstLaunch();
setTimeout(async () => {
await Notifications.get();
if (SettingsService.get().notifNotes) {
Notifications.pinQuickNote();
Notifications.pinQuickNote(true);
}
TipManager.init();
}, 100);
@@ -109,10 +100,8 @@ let currTheme =
: SettingsService.getProperty("lighTheme");
useThemeEngineStore.getState().setTheme(currTheme);
export const withTheme = (
Element: (props: PropsWithChildren) => JSX.Element
) => {
return function AppWithThemeProvider(props: PropsWithChildren) {
export const withTheme = (Element: (props: any) => JSX.Element) => {
return function AppWithThemeProvider(props: any) {
const [colorScheme, darkTheme, lightTheme] = useThemeStore((state) => [
state.colorScheme,
state.darkTheme,
@@ -132,12 +121,8 @@ export const withTheme = (
.then((theme) => {
if (theme) {
theme.colorScheme === "dark"
? useThemeStore.setState({
darkTheme: theme
})
: useThemeStore.setState({
lightTheme: theme
});
? useThemeStore.getState().setDarkTheme(theme)
: useThemeStore.getState().setLightTheme(theme);
}
})
.catch(() => {
@@ -147,9 +132,7 @@ export const withTheme = (
const listener = Appearance.addChangeListener(({ colorScheme }) => {
if (colorScheme && SettingsService.getProperty("useSystemTheme")) {
useThemeStore.setState({
colorScheme: colorScheme as "light" | "dark"
});
useThemeStore.getState().setColorScheme(colorScheme);
}
});
return () => {

View File

@@ -25,6 +25,7 @@ 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
@@ -93,7 +94,9 @@ export async function encryptDatabaseKeyWithPassword(appLockPassword: string) {
}
export async function restoreDatabaseKeyToKeyChain(appLockPassword: string) {
const databaseKeyCipher = CipherStorage.getMap(DB_KEY_CIPHER) as Cipher;
const databaseKeyCipher: Cipher = CipherStorage.getMap(
DB_KEY_CIPHER
) as Cipher;
const databaseKey = (await decrypt(
{
password: appLockPassword
@@ -204,8 +207,9 @@ export async function getDatabaseKey(appLockPassword?: string) {
}
if (await Keychain.hasInternetCredentials("notesnook")) {
const userKeyCredentials =
await Keychain.getInternetCredentials("notesnook");
const userKeyCredentials = await Keychain.getInternetCredentials(
"notesnook"
);
if (userKeyCredentials) {
const userKeyCipher: Cipher = (await encrypt(

View File

@@ -72,8 +72,8 @@ export async function setupDatabase(password?: string) {
({
compress: Gzip.deflate,
decompress: Gzip.inflate
}) as ICompressor,
batchSize: 50,
} as ICompressor),
batchSize: 100,
sqliteOptions: {
dialect: (name) => ({
createDriver: () => {

View File

@@ -109,8 +109,8 @@ class RNSqliteConnection implements DatabaseConnection {
query.kind === "SelectQueryNode"
? "query"
: query.kind === "RawNode"
? "raw"
: "exec";
? "raw"
: "exec";
const result = await this.db.executeAsync(sql, parameters as any[]);

View File

@@ -70,7 +70,7 @@ export async function downloadFile(
throw new Error(error);
}
const totalChunks = Math.ceil(size / (chunkSize + ABYTES));
const totalChunks = Math.ceil(size / chunkSize);
const decryptedLength = size - totalChunks * ABYTES;
if (attachment && attachment.size !== decryptedLength) {
@@ -115,13 +115,7 @@ export async function downloadFile(
.progress(async (recieved, total) => {
useAttachmentStore
.getState()
.setProgress(
0,
parseInt(total),
filename,
parseInt(recieved),
"download"
);
.setProgress(0, total, filename, recieved, "download");
DatabaseLogger.log(`Downloading: ${filename}, ${recieved}/${total}`);
});

View File

@@ -30,8 +30,7 @@ import {
getCacheSize,
hashBase64,
readEncrypted,
writeEncryptedBase64,
bulkDeleteFiles
writeEncryptedBase64
} from "./io";
import { uploadFile } from "./upload";
import {
@@ -62,6 +61,5 @@ export const FileStorage: IFileStorage = {
exists,
clearFileStorage,
getUploadedFileSize,
bulkExists,
bulkDeleteFiles
bulkExists
};

View File

@@ -30,14 +30,7 @@ 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,
isSuccessStatusCode,
parseS3Error
} from "./utils";
import { ABYTES, cacheDir, cacheDirOld, getRandomId } from "./utils";
export async function readEncrypted<TOutputFormat extends DataFormat>(
filename: string,
@@ -109,41 +102,16 @@ export async function writeEncryptedBase64(
};
}
async function deleteLocalFile(filename: string) {
try {
await createCacheDir();
const path = cacheDir + `/${filename}`;
const exists = await RNFetchBlob.fs.exists(path);
if (Platform.OS === "ios" && !exists) {
const iosAppGroup =
Platform.OS === "ios"
? await (RNFetchBlob.fs as any).pathForAppGroup(IOS_APPGROUPID)
: null;
const appGroupPath = `${iosAppGroup}/${filename}`;
if (await RNFetchBlob.fs.exists(appGroupPath)) {
RNFetchBlob.fs.unlink(appGroupPath).catch(() => {
/* empty */
});
return;
}
}
if (exists) {
RNFetchBlob.fs.unlink(path).catch(() => {
/* empty */
});
}
} catch (e) {
DatabaseLogger.error(e as Error, "deleteLocalFile");
}
}
export async function deleteFile(
filename: string,
requestOptions?: RequestOptions
): Promise<boolean> {
await createCacheDir();
const localFilePath = cacheDir + `/${filename}`;
if (!requestOptions) {
deleteLocalFile(filename);
RNFetchBlob.fs.unlink(localFilePath).catch(() => {
/* empty */
});
return true;
}
@@ -154,7 +122,9 @@ export async function deleteFile(
const status = response.info().status;
const ok = status >= 200 && status < 300;
if (ok) {
deleteLocalFile(filename);
RNFetchBlob.fs.unlink(localFilePath).catch(() => {
/* empty */
});
}
return ok;
} catch (e) {
@@ -165,49 +135,6 @@ export async function deleteFile(
}
}
export async function bulkDeleteFiles(
filenames: string[],
requestOptions?: RequestOptions
) {
await createCacheDir();
if (!requestOptions) {
filenames.forEach((filename) => {
deleteLocalFile(filename);
});
return true;
}
try {
const { url, headers } = requestOptions;
const response = await fetch(url, {
method: "POST",
headers: {
...headers,
"Content-Type": "application/json"
},
body: JSON.stringify({
names: filenames
})
});
const result = isSuccessStatusCode(response.status);
if (result) {
filenames.forEach((filename) => {
deleteLocalFile(filename);
});
} else {
throw await response.text();
}
return result;
} catch (e) {
DatabaseLogger.error(
typeof e === "string" ? parseS3Error(e as string) : (e as Error),
"Could not bulk delete files"
);
return false;
}
}
export async function clearFileStorage() {
try {
await createCacheDir();
@@ -295,22 +222,14 @@ export async function deleteCacheFileByName(name: string) {
}
export async function deleteDCacheFiles() {
try {
await createCacheDir();
const files = await RNFetchBlob.fs.ls(cacheDir);
for (const file of files) {
if (
file.includes("_dcache") ||
file.startsWith("NN_") ||
file.endsWith(".pdf")
) {
await RNFetchBlob.fs.unlink(file).catch(() => {
/* empty */
});
}
await createCacheDir();
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 */
});
}
} catch (e) {
/** Empty */
}
}

View File

@@ -17,8 +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 { isImage, RequestOptions, hosts } from "@notesnook/core";
import { PermissionsAndroid, Platform } from "react-native";
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";
@@ -31,138 +31,6 @@ import {
FileSizeResult,
getUploadedFileSize
} from "./utils";
import Upload from "@ammarahmed/react-native-upload";
import { CloudUploader } from "react-native-nitro-cloud-uploader";
import { useUserStore } from "../../stores/use-user-store";
import { sleep } from "../../utils/time";
import { isFeatureAvailable } from "@notesnook/common";
import { strings } from "@notesnook/intl";
// Upload constants
const CHUNK_SIZE = 10 * 1024 * 1024; // 10 MB
const MINIMUM_MULTIPART_FILE_SIZE = 25 * 1024 * 1024; // 25MB
interface InitiateMultipartResponse {
uploadId: string;
parts: string[];
error?: string;
}
async function initiateMultipartUpload(
filename: string,
fileSize: number,
headers: Record<string, string>
): Promise<InitiateMultipartResponse> {
const totalParts = Math.ceil(fileSize / CHUNK_SIZE);
const url = `${hosts.API_HOST}/s3/multipart?name=${filename}&parts=${totalParts}&uploadId=`;
const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(
`Failed to initiate multipart upload: ${response.statusText}`
);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
if (!data.uploadId || !data.parts) {
throw new Error("Failed to initiate multipart upload: invalid response.");
}
DatabaseLogger.info(
`Initiated multipart upload for ${filename} with upload ID: ${data.uploadId}`
);
return data;
}
async function multipartUploadFile(
filename: string,
filePath: string,
fileSize: number,
requestOptions: RequestOptions,
cancelToken: { cancel: (reason?: string) => Promise<void> }
): Promise<Response> {
const { headers } = requestOptions;
try {
const uploadData = await initiateMultipartUpload(
filename,
fileSize,
headers
);
const { uploadId, parts } = uploadData;
DatabaseLogger.info(
`Starting upload for ${filename} with ${parts.length} parts`
);
cancelToken.cancel = async () => {
useAttachmentStore.getState().remove(filename);
await CloudUploader.cancelUpload(uploadId);
};
CloudUploader.addListener("upload-progress", (event) => {
useAttachmentStore
.getState()
.setProgress(
event.bytesUploaded || 0,
event.totalBytes || fileSize,
filename,
0,
"upload"
);
DatabaseLogger.info(
`File upload progress: ${filename}, ${event.bytesUploaded}/${
event.totalBytes || fileSize
}, chunk: ${event.chunkIndex}, progress: ${event.progress}`
);
});
// CloudUploader handles chunking and uploading all parts internally
const result = await CloudUploader.startUpload(
filename,
filePath,
parts,
3, // maxParallel
true // showNotification
);
CloudUploader.removeListener("upload-progress");
if (!result.success) {
throw new Error("Failed to upload multipart file");
}
DatabaseLogger.info(
`Multipart upload completed for ${filename} with upload ID: ${uploadId}`
);
const response = await fetch(`${hosts.API_HOST}/s3/multipart`, {
method: "POST",
body: JSON.stringify({
Key: filename,
UploadId: uploadId,
PartETags: result.etags.map((etag, index) => ({
partNumber: index + 1,
etag: etag
}))
}),
headers: { ...headers, "Content-Type": "application/json" }
});
return response;
} catch (error) {
DatabaseLogger.error(error, "Multipart upload failed", { filename });
CloudUploader.removeListener("upload-progress");
useAttachmentStore.getState().remove(filename);
throw error;
}
}
export async function uploadFile(
filename: string,
@@ -196,163 +64,63 @@ export async function uploadFile(
);
}
const fileInfo = await RNFetchBlob.fs.stat(filePath);
const fileSize = (await RNFetchBlob.fs.stat(filePath)).size;
const remoteFileSize = await getUploadedFileSize(filename);
if (remoteFileSize === FileSizeResult.Error) return false;
const featureResult = await isFeatureAvailable(
"fileSize",
fileInfo.size || 0
);
if (!featureResult.isAllowed) {
ToastManager.show({
heading: strings.fileTooLarge(),
message: featureResult.error,
type: "error"
});
return false;
}
if (
remoteFileSize > FileSizeResult.Empty &&
remoteFileSize === fileInfo.size
) {
if (remoteFileSize > FileSizeResult.Empty && remoteFileSize === fileSize) {
DatabaseLogger.log(`File ${filename} is already uploaded.`);
return true;
}
let attachmentInfo = await db.attachments.attachment(filename);
DatabaseLogger.info(`Starting upload: ${filename}`);
DatabaseLogger.info(
`Starting upload of ${filename} at path: ${fileInfo.path} ${fileInfo.size}`
);
if (Platform.OS === "android") {
useUserStore.setState({
disableAppLockRequests: true
});
const status = await PermissionsAndroid.request(
"android.permission.POST_NOTIFICATIONS"
);
if (status !== "granted") {
ToastManager.show({
message: `The permission to show file upload notification was disallowed by the user.`,
type: "info"
});
}
await sleep(500);
useUserStore.setState({
disableAppLockRequests: false
});
}
let uploaded = false;
// Use multipart upload for files larger than MINIMUM_MULTIPART_FILE_SIZE
if (fileInfo.size >= MINIMUM_MULTIPART_FILE_SIZE) {
DatabaseLogger.info(
`Using multipart upload for large file: ${filename} (${fileInfo.size} bytes)`
);
const result = await multipartUploadFile(
filename,
filePath,
fileInfo.size,
requestOptions,
cancelToken
);
const status = result.status || 0;
uploaded = status >= 200 && status < 300;
if (!uploaded) {
const fileInfo = await RNFetchBlob.fs.stat(filePath);
throw new Error(
`${status}, name: ${fileInfo.filename}, length: ${
fileInfo.size
}, info: ${JSON.stringify(await result.text())}`
);
}
} else {
// Use single-part upload for smaller files
DatabaseLogger.info(
`Using single-part upload for file: ${filename} (${fileInfo.size} bytes)`
);
const upload = Upload.create({
customUploadId: filename,
path: Platform.OS === "ios" ? "file://" + fileInfo.path : fileInfo.path,
url: url,
method: "PUT",
headers: {
const uploadRequest = RNFetchBlob.config({
//@ts-ignore
IOSBackgroundTask: !globalThis["IS_SHARE_EXTENSION"]
})
.fetch(
"PUT",
url,
{
...headers,
"content-type": "application/octet-stream"
"content-type": ""
},
appGroup: IOS_APPGROUPID,
notification: {
filename:
attachmentInfo && isImage(attachmentInfo?.mimeType)
? "image"
: attachmentInfo?.filename || "file",
enabled: true,
enableRingTone: true,
autoClear: true
}
}).onChange((event) => {
switch (event.status) {
case "running":
case "pending":
useAttachmentStore
.getState()
.setProgress(
event.uploadedBytes || 0,
event.totalBytes || fileInfo.size,
filename,
0,
"upload"
);
DatabaseLogger.info(
`File upload progress: ${filename}, ${event.uploadedBytes}/${
event.totalBytes || fileInfo.size
}`
);
break;
case "completed":
DatabaseLogger.info("Upload completed");
break;
}
});
const result = await upload.start();
cancelToken.cancel = async () => {
useAttachmentStore.getState().remove(filename);
upload.cancel();
};
const status = result.responseCode || 0;
uploaded = status >= 200 && status < 300;
if (!uploaded) {
const fileInfo = await RNFetchBlob.fs.stat(filePath);
throw new Error(
`${status}, name: ${fileInfo.filename}, length: ${
fileInfo.size
}, info: ${JSON.stringify(result.error)}`
RNFetchBlob.wrap(filePath)
)
.uploadProgress((sent, total) => {
useAttachmentStore
.getState()
.setProgress(sent, total, filename, 0, "upload");
DatabaseLogger.info(
`File upload progress: ${filename}, ${sent}/${total}`
);
}
}
});
cancelToken.cancel = async () => {
useAttachmentStore.getState().remove(filename);
uploadRequest.cancel();
};
const uploadResponse = await uploadRequest;
const status = uploadResponse.info().status;
const uploaded = status >= 200 && status < 300;
useAttachmentStore.getState().remove(filename);
if (uploaded) {
attachmentInfo = await db.attachments.attachment(filename);
if (!attachmentInfo) return false;
await checkUpload(
filename,
requestOptions.chunkSize,
attachmentInfo.size
if (!uploaded) {
const fileInfo = await RNFetchBlob.fs.stat(filePath);
throw new Error(
`${status}, name: ${fileInfo.filename}, length: ${
fileInfo.size
}, info: ${JSON.stringify(uploadResponse.info())}`
);
DatabaseLogger.info(`File upload status: ${filename}, success`);
}
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;
} catch (e) {
useAttachmentStore.getState().remove(filename);

View File

@@ -155,7 +155,7 @@ export async function checkUpload(
expectedSize: number
) {
const size = await getUploadedFileSize(filename);
const totalChunks = Math.ceil(size / (chunkSize + ABYTES));
const totalChunks = Math.ceil(size / chunkSize);
const decryptedLength = size - totalChunks * ABYTES;
const error =
size === 0
@@ -189,11 +189,3 @@ export async function checkAndCreateDir(path: string) {
}
return dir;
}
export const santizeUri = (uri: string) => {
return Platform.OS === "ios" ? decodeURI(uri).replace("file:///", "/") : uri;
};
export function isSuccessStatusCode(statusCode: number) {
return statusCode >= 200 && statusCode <= 299;
}

View File

@@ -30,13 +30,13 @@ import { AppFontSize } from "../../utils/size";
import { Button } from "../ui/button";
import { strings } from "@notesnook/intl";
export const Announcement = () => {
export const Announcement = ({ color }) => {
const { colors } = useThemeColors();
const [announcements, remove] = useMessageStore((state) => [
state.announcements,
state.remove
]);
const announcement = announcements.length > 0 ? announcements[0] : null;
let announcement = announcements.length > 0 ? announcements[0] : null;
const selectionMode = useSelectionStore((state) => state.selectionMode);
return !announcement || selectionMode ? null : (
@@ -82,6 +82,7 @@ export const Announcement = () => {
<Button
type="plain"
icon="close"
height={null}
onPress={() => {
remove(announcement.id);
}}
@@ -101,6 +102,7 @@ export const Announcement = () => {
renderItem({
item: item,
index: index,
color: colors[color],
inline: true
})
)}

View File

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

View File

@@ -19,25 +19,24 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import React from "react";
import { Linking, View } from "react-native";
//import SettingsBackupAndRestore from '../../screens/settings/backup-restore';
import { useThemeColors } from "@notesnook/theme";
import { eSendEvent } from "../../services/event-manager";
import { eSendEvent, presentSheet } from "../../services/event-manager";
import { eCloseAnnouncementDialog } from "../../utils/events";
import { AppFontSize } from "../../utils/size";
import { sleep } from "../../utils/time";
import SheetProvider from "../sheet-provider";
import { Button } from "../ui/button";
import { allowedOnPlatform, BodyItemProps, getStyle } from "./functions";
import { allowedOnPlatform, getStyle } from "./functions";
import { DefaultAppStyles } from "../../utils/styles";
import { Action } from "../../stores/use-message-store";
export const Cta = (props: BodyItemProps) => {
export const Cta = ({ actions, style = {}, color, inline }) => {
const { colors } = useThemeColors();
const buttons =
props.item.actions.filter((item) => allowedOnPlatform(item.platforms)) ||
[];
let buttons =
actions.filter((item) => allowedOnPlatform(item.platforms)) || [];
const onPress = async (item: Action) => {
if (!props.inline) {
const onPress = async (item) => {
if (!inline) {
eSendEvent(eCloseAnnouncementDialog);
await sleep(500);
}
@@ -51,14 +50,14 @@ export const Cta = (props: BodyItemProps) => {
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP,
...getStyle(props.item.style),
flexDirection: props.inline ? "row" : "column",
...getStyle(style),
flexDirection: inline ? "row" : "column",
gap: DefaultAppStyles.GAP_SMALL
}}
>
<SheetProvider context="premium_cta" />
{props.inline ? (
{inline ? (
<>
{buttons.length > 0 &&
buttons.slice(0, 1).map((item) => (
@@ -107,9 +106,11 @@ export const Cta = (props: BodyItemProps) => {
key={item.title}
title={item.title}
buttonType={{
color: colors.primary.accent,
text: colors.primary.accentForeground,
selected: colors.primary.accent,
color: color ? color : colors.primary.accent,
text: color
? colors.static.white
: colors.primary.accentForeground,
selected: color ? color : colors.primary.accent,
opacity: 1
}}
onPress={() => onPress(item)}

View File

@@ -20,20 +20,20 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import React from "react";
import { AppFontSize } from "../../utils/size";
import Paragraph from "../ui/typography/paragraph";
import { BodyItemProps, getStyle } from "./functions";
import { getStyle } from "./functions";
import { DefaultAppStyles } from "../../utils/styles";
export const Description = (props: BodyItemProps) => {
export const Description = ({ text, style = {}, inline }) => {
return (
<Paragraph
style={{
paddingHorizontal: DefaultAppStyles.GAP,
...getStyle(props.item.style),
textAlign: props.inline ? "left" : props.item.style?.textAlign
...getStyle(style),
textAlign: inline ? "left" : style?.textAlign
}}
size={AppFontSize.sm}
>
{props.item.text}
{text}
</Paragraph>
);
};

View File

@@ -19,8 +19,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import React, { Fragment } from "react";
import { View } from "react-native";
import { allowedPlatforms, BodyItem } from "../../stores/use-message-store";
import { DefaultAppStyles } from "../../utils/styles";
import { allowedPlatforms } from "../../stores/use-message-store";
import { ProFeatures } from "../dialogs/result/pro-features";
import { Body } from "./body";
import { Cta } from "./cta";
import { Description } from "./description";
@@ -28,8 +28,9 @@ 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: string[]) {
export function allowedOnPlatform(platforms) {
if (!platforms) return true;
return platforms.some((platform) => allowedPlatforms.indexOf(platform) > -1);
}
@@ -40,15 +41,11 @@ export const margins = {
2: 20
};
export const getStyle = (style: BodyItem["style"]) => {
export const getStyle = (style) => {
if (!style) return {};
return {
marginTop: style.marginTop
? margins[style.marginTop as keyof typeof margins] || 0
: 0,
marginBottom: style.marginBottom
? margins[style.marginBottom as keyof typeof margins] || 0
: 0,
marginTop: margins[style.marginTop] || 0,
marginBottom: margins[style.marginBottom] || 0,
textAlign: style.textAlign || "left"
};
};
@@ -61,7 +58,9 @@ const Features = () => {
alignItems: "center",
width: "100%"
}}
></View>
>
<ProFeatures />
</View>
);
};
@@ -77,25 +76,16 @@ const renderItems = {
callToActions: Cta
};
export const renderItem = ({
item,
index,
inline
}: {
item: BodyItem;
index: number;
inline?: boolean;
}) => {
const Item = renderItems[item.type as keyof typeof renderItems] || Fragment;
export const renderItem = ({ item, index, color, inline }) => {
const Item = renderItems[item.type] || Fragment;
return (
<Item
key={item.text || item.src || item.type}
item={item}
{...item}
index={index}
color={color}
inline={inline}
/>
);
};
export type BodyItemProps = { item: BodyItem; index: number; inline?: boolean };

View File

@@ -24,7 +24,7 @@ import {
eSubscribeEvent,
eUnSubscribeEvent
} from "../../services/event-manager";
import { Announcement, useMessageStore } from "../../stores/use-message-store";
import { useMessageStore } from "../../stores/use-message-store";
import { useThemeColors } from "@notesnook/theme";
import {
eCloseAnnouncementDialog,
@@ -97,24 +97,9 @@ import { DefaultAppStyles } from "../../utils/styles";
export const AnnouncementDialog = () => {
const { colors } = useThemeColors();
const [visible, setVisible] = useState(false);
const [info, setInfo] = useState<Announcement | undefined>(undefined);
const [info, setInfo] = useState();
const remove = useMessageStore((state) => state.remove);
const open = (data: Announcement) => {
setInfo(data);
setImmediate(() => {
setVisible(true);
});
};
const close = useCallback(() => {
if (visible) {
if (info?.id) remove(info?.id);
setInfo(undefined);
setVisible(false);
}
}, [info?.id, remove, visible]);
useEffect(() => {
eSubscribeEvent(eOpenAnnouncementDialog, open);
eSubscribeEvent(eCloseAnnouncementDialog, close);
@@ -124,6 +109,21 @@ export const AnnouncementDialog = () => {
};
}, [close, visible]);
const open = (data) => {
setInfo(data);
setImmediate(() => {
setVisible(true);
});
};
const close = useCallback(() => {
if (visible) {
remove(info?.id);
setInfo(null);
setVisible(false);
}
}, [info?.id, remove, visible]);
return (
<BaseDialog
animated={false}

View File

@@ -21,19 +21,19 @@ import React from "react";
import { View } from "react-native";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import Paragraph from "../ui/typography/paragraph";
import { BodyItemProps, getStyle } from "./functions";
import { getStyle } from "./functions";
import { DefaultAppStyles } from "../../utils/styles";
export const List = (props: BodyItemProps) => {
export const List = ({ items, listType, style = {} }) => {
return (
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP,
paddingLeft: props.item.listType === "ordered" ? 25 : 25,
...getStyle(props.item.style)
paddingLeft: listType === "ordered" ? 25 : 25,
...getStyle(style)
}}
>
{props.item.items?.map((item, index) => (
{items.map((item, index) => (
<View
key={item.text}
style={{
@@ -41,7 +41,7 @@ export const List = (props: BodyItemProps) => {
flexDirection: "row"
}}
>
{props.item.listType === "ordered" ? (
{listType === "ordered" ? (
<Paragraph
style={{
marginRight: 5

View File

@@ -19,18 +19,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import React from "react";
import { Image } from "react-native";
import { BodyItemProps, getStyle } from "./functions";
import { getStyle } from "./functions";
export const Photo = (props: BodyItemProps) => {
return props.item.src ? (
export const Photo = ({ src, style = {} }) => {
return src ? (
<Image
source={{ uri: props.item.src }}
source={{ uri: src }}
resizeMode="cover"
style={{
width: "100%",
height: 200,
alignSelf: "center",
...getStyle(props.item.style)
...getStyle(style)
}}
/>
) : null;

View File

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

View File

@@ -19,42 +19,45 @@ 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 { DefaultAppStyles } from "../../utils/styles";
import { Button } from "../ui/button";
import Heading from "../ui/typography/heading";
import { BodyItemProps, getStyle } from "./functions";
import { getStyle } from "./functions";
import { DefaultAppStyles } from "../../utils/styles";
export const Title = (props: BodyItemProps) => {
return props.inline ? (
export const Title = ({ text, style = {}, inline }) => {
return inline ? (
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: props.inline ? DefaultAppStyles.GAP_VERTICAL_SMALL : 0
marginBottom: inline ? DefaultAppStyles.GAP_VERTICAL_SMALL : 0
}}
>
<Heading
style={{
paddingHorizontal: DefaultAppStyles.GAP,
marginTop: DefaultAppStyles.GAP_VERTICAL,
...getStyle(props.item.style),
textAlign: props.inline ? "left" : props.item.style?.textAlign,
...getStyle(style),
textAlign: inline ? "left" : style?.textAlign,
flexShrink: 1
}}
size={props.inline ? AppFontSize.md : AppFontSize.xl}
size={inline ? AppFontSize.md : AppFontSize.xl}
>
{props.item.text?.toUpperCase()}
{inline ? text?.toUpperCase() : text}
</Heading>
</View>
) : (
<Heading
style={{
paddingHorizontal: DefaultAppStyles.GAP,
...getStyle(props.item.style)
...getStyle(style),
marginTop: style?.marginTop || DefaultAppStyles.GAP_VERTICAL
}}
>
{props.item.text}
{text}
</Heading>
);
};

View File

@@ -53,6 +53,7 @@ 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";
@@ -91,7 +92,7 @@ const AppLocked = () => {
const lockApp = useUserStore((state) => state.lockApp);
const deviceMode = useSettingStore((state) => state.deviceMode);
const passwordInputRef = useRef<TextInput>(null);
const password = useRef<string>(undefined);
const password = useRef<string>();
const appState = useAppState();
const lastAppState = useRef<AppStateStatus>(appState);
const biometricUnlockAwaitingUserInput = useRef(false);
@@ -238,8 +239,8 @@ const AppLocked = () => {
deviceMode !== "mobile"
? "50%"
: Platform.OS == "ios"
? "95%"
: "100%",
? "95%"
: "100%",
paddingHorizontal: 12,
marginBottom: 30,
marginTop: 15,

View File

@@ -22,7 +22,7 @@ import { Attachment, Note, VirtualizedGrouping } from "@notesnook/core";
import { useThemeColors } from "@notesnook/theme";
import Clipboard from "@react-native-clipboard/clipboard";
import React, { RefObject, useEffect, useState } from "react";
import { TextInput, View } from "react-native";
import { View } from "react-native";
import { ActionSheetRef } from "react-native-actions-sheet";
import { ScrollView } from "react-native-gesture-handler";
import { db } from "../../common/database";
@@ -46,6 +46,7 @@ import {
eOnLoadNote
} from "../../utils/events";
import { AppFontSize } from "../../utils/size";
import { sleep } from "../../utils/time";
import { Dialog } from "../dialog";
import { presentDialog } from "../dialog/functions";
import { openNote } from "../list-items/note/wrapper";
@@ -58,8 +59,6 @@ import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { strings } from "@notesnook/intl";
import { DefaultAppStyles } from "../../utils/styles";
import Navigation from "../../services/navigation";
import { createFormRef, validators } from "../ui/input/form-input";
const Actions = ({
attachment,
@@ -154,100 +153,50 @@ const Actions = ({
{
name: strings.rename(),
onPress: () => {
close?.();
setTimeout(() => {
presentDialog({
title: strings.renameFile(),
form: {
formRef: createFormRef({
name: attachment.filename
}),
items: [
{
name: "name",
defaultValue: attachment.filename,
placeholder: strings.enterTitle(),
ref: React.createRef<TextInput | null>(),
validators: [validators.required(strings.nameIsRequired())]
}
],
onFormSubmit: async (form) => {
try {
const value = form.getValue("name");
await db.attachments.add({
hash: attachment.hash,
filename: value
});
setFilename(value);
setAttachments();
eSendEvent(eDBItemUpdate, attachment.id);
ToastManager.show({
message: `Attachment renamed to ${value}`,
type: "success"
});
return true;
} catch (e) {
form.setError("name", (e as Error).message);
return false;
}
}
},
positiveText: strings.rename()
});
}, 500);
presentDialog({
input: true,
title: strings.renameFile(),
defaultValue: attachment.filename,
positivePress: async (value) => {
if (value && value.length > 0) {
await db.attachments.add({
hash: attachment.hash,
filename: value
});
setFilename(value);
setAttachments();
eSendEvent(eDBItemUpdate, attachment.id);
}
return true;
},
positiveText: strings.rename()
});
},
icon: "form-textbox"
},
{
name: strings.delete(),
onPress: async () => {
close?.();
setTimeout(() => {
presentDialog({
title: strings.deleteAttachment(),
paragraph: strings.deleteAttachmentConfirm(),
positiveText: strings.yes(),
negativeText: strings.no(),
positiveType: "errorShade",
positivePress: async () => {
try {
const relations = await db.relations
.to(attachment, "note")
.get();
await db.attachments.remove(attachment.hash, false);
ToastManager.show({
type: "success",
message: strings.attachmentDeleted()
const relations = await db.relations.to(attachment, "note").get();
await db.attachments.remove(attachment.hash, false);
setAttachments();
eSendEvent(eDBItemUpdate, attachment.id);
relations
.map((relation) => relation.fromId)
.forEach(async (id) => {
useTabStore.getState().forEachNoteTab(id, async (tab) => {
const isFocused = useTabStore.getState().currentTab === tab.id;
if (isFocused) {
eSendEvent(eOnLoadNote, {
item: await db.notes.note(id),
forced: true
});
setAttachments();
eSendEvent(eDBItemUpdate, attachment.id);
relations
.map((relation) => relation.fromId)
.forEach(async (id) => {
useTabStore.getState().forEachNoteTab(id, async (tab) => {
const isFocused =
useTabStore.getState().currentTab === tab.id;
if (isFocused) {
eSendEvent(eOnLoadNote, {
item: await db.notes.note(id),
forced: true
});
} else {
editorController.current.commands.setLoading(
true,
tab.id
);
}
});
});
return true;
} catch (e) {
return false;
} else {
editorController.current.commands.setLoading(true, tab.id);
}
}
});
});
}, 500);
close?.();
},
icon: "delete-outline"
}
@@ -355,9 +304,9 @@ const Actions = ({
<Pressable
onPress={async () => {
eSendEvent(eCloseSheet, contextId);
close?.();
await sleep(150);
eSendEvent(eCloseAttachmentDialog);
Navigation.navigate("FluidPanelsView");
await sleep(300);
openNote(item, (item as any).type === "trash");
}}
style={{

View File

@@ -27,10 +27,11 @@ import {
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useEffect, useRef, useState } from "react";
import { ActivityIndicator, FlatList, View } from "react-native";
import { ScrollView } from "react-native-actions-sheet";
import { ActivityIndicator, 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 from "zustand";
import create, { State } from "zustand";
import { db } from "../../common/database";
import filesystem from "../../common/filesystem";
import { downloadAttachments } from "../../common/filesystem/download-attachment";
@@ -38,7 +39,6 @@ import { AttachmentGroupProgress } from "../../screens/settings/attachment-group
import { presentSheet, ToastManager } from "../../services/event-manager";
import { useAttachmentStore } from "../../stores/use-attachment-store";
import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { Dialog } from "../dialog";
import { presentDialog } from "../dialog/functions";
import { Header } from "../header";
@@ -50,6 +50,7 @@ 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";
const DEFAULT_SORTING: SortOptions = {
sortBy: "dateEdited",
@@ -64,7 +65,7 @@ type RecheckerProgress = {
filter: string;
};
interface RecheckerState {
interface RecheckerState extends State {
progress: {
[key: string]: RecheckerProgress;
};
@@ -134,9 +135,9 @@ export const AttachmentDialog = ({
const { colors } = useThemeColors();
const [attachments, setAttachments] =
useState<VirtualizedGrouping<Attachment>>();
const attachmentSearchValue = useRef<string>(undefined);
const attachmentSearchValue = useRef<string>();
const [loading, setLoading] = useState(true);
const searchTimer = useRef<NodeJS.Timeout>(undefined);
const searchTimer = useRef<NodeJS.Timeout>();
const [currentFilter, setCurrentFilter] = useState("all");
const rechecker = useRechecker((state) =>
note ? state.progress[note.id] || {} : state.progress.all
@@ -198,7 +199,7 @@ export const AttachmentDialog = ({
}, 300);
};
const renderItem = ({ index }: { item: boolean | number; index: number }) => (
const renderItem = ({ index }: { item: boolean; index: number }) => (
<AttachmentItem
setAttachments={async () => {
setAttachments(await filterAttachments(currentFilter));
@@ -312,10 +313,6 @@ export const AttachmentDialog = ({
});
};
db.attachments.orphaned.items().then((r) => {
console.log(r);
});
return (
<>
{isSheet ? (
@@ -329,6 +326,31 @@ export const AttachmentDialog = ({
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
@@ -512,8 +534,7 @@ export const AttachmentDialog = ({
</ScrollView>
</View>
<FlatList
renderScrollComponent={(props) => <ScrollView {...props} />}
<FlashList
keyboardDismissMode="none"
keyboardShouldPersistTaps="always"
ListEmptyComponent={
@@ -546,8 +567,8 @@ export const AttachmentDialog = ({
}}
/>
}
data={loading ? [] : attachments?.placeholders || []}
extraData={attachments}
estimatedItemSize={50}
data={loading ? [] : attachments?.placeholders}
renderItem={renderItem}
/>
</View>

View File

@@ -0,0 +1,114 @@
/*
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 { useThemeColors } from "@notesnook/theme";
import React, { useEffect, useRef, useState } from "react";
import {
eSubscribeEvent,
eUnSubscribeEvent
} from "../../services/event-manager";
import { useUserStore } from "../../stores/use-user-store";
import { eCloseLoginDialog, eOpenLoginDialog } from "../../utils/events";
import { sleep } from "../../utils/time";
import BaseDialog from "../dialog/base-dialog";
import { Toast } from "../toast";
import { 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,
signup: 1,
welcomeSignup: 2,
trialSignup: 3
};
const AuthModal = () => {
const { colors } = useThemeColors();
const [visible, setVisible] = useState(false);
const [currentAuthMode, setCurrentAuthMode] = useState(AuthMode.login);
const actionSheetRef = useRef();
useEffect(() => {
eSubscribeEvent(eOpenLoginDialog, open);
eSubscribeEvent(eCloseLoginDialog, close);
return () => {
eUnSubscribeEvent(eOpenLoginDialog, open);
eUnSubscribeEvent(eCloseLoginDialog, close);
};
}, []);
async function open(mode) {
setCurrentAuthMode(mode ? mode : AuthMode.login);
initialAuthMode.current = mode ? mode : AuthMode.login;
setVisible(true);
await sleep(10);
actionSheetRef.current?.show();
}
const close = () => {
useUserStore.setState({
disableAppLockRequests: false
});
actionSheetRef.current?.hide();
setCurrentAuthMode(AuthMode.login);
setVisible(false);
};
return !visible ? null : (
<BaseDialog
overlayOpacity={0}
statusBarTranslucent={false}
onShow={() => {
useUserStore.setState({
disableAppLockRequests: true
});
}}
onRequestClose={currentAuthMode !== AuthMode.welcomeSignup && close}
visible={true}
onClose={close}
useSafeArea={false}
bounce={false}
background={colors.primary.background}
transparent={false}
animated={false}
centered={false}
enableSheetKeyboardHandler
>
{currentAuthMode !== AuthMode.login ? (
<Signup
changeMode={(mode) => setCurrentAuthMode(mode)}
trial={AuthMode.trialSignup === currentAuthMode}
welcome={initialAuthMode.current === AuthMode.welcomeSignup}
/>
) : (
<Login
welcome={initialAuthMode.current === AuthMode.welcomeSignup}
changeMode={(mode) => setCurrentAuthMode(mode)}
/>
)}
<Toast context="local" />
</BaseDialog>
);
};
export default AuthModal;

View File

@@ -0,0 +1,21 @@
/*
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/>.
*/
export const SVG = (color) =>
`<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="1920" height="1080" preserveAspectRatio="none" viewBox="0 0 1920 1080"><g fill="none"><path d="M684.78-215.3C516.78-49.87 619.54 659.12 349.29 666.13 79.03 673.14-116.04 204.76-321.7 180.13" stroke="${color}" stroke-width="2"></path><path d="M1337.71-20.29C1105.2 122.03 1140.53 887.64 809.58 933.71 478.63 979.78 545.52 798.71 281.45 798.71 17.39 798.71-110.41 932.65-246.68 933.71" stroke="${color}" stroke-width="2"></path><path d="M1631.89-52.3C1379.35-21.93 1283.05 511.66 808.42 545.28 333.79 578.9 209.15 894.14-15.05 901.68" stroke="${color}" stroke-width="2"></path><path d="M1523.21-71.7C1244.4-46.38 1056.2 532.95 581.02 533.49 105.84 534.03-109.4 182.98-361.17 177.09" stroke="${color}" stroke-width="2"></path><path d="M1333.35-39.1C1041.86 57 974.42 893.52 550.36 906.48 126.3 919.44-23.8 619.56-232.63 614.88" stroke="${color}" stroke-width="2"></path></g><defs><mask id="SvgjsMask1032"><rect width="1920" height="1080" fill="#ffffff"></rect></mask></defs></svg>`;

View File

@@ -31,13 +31,12 @@ import { Dialog } from "../dialog";
import { Button } from "../ui/button";
import Input from "../ui/input";
import { Notice } from "../ui/notice";
import { TextInput } from "react-native-gesture-handler";
export const ChangePassword = () => {
const passwordInputRef = useRef<TextInput>(null);
const password = useRef<string>(undefined);
const oldPasswordInputRef = useRef<TextInput>(null);
const oldPassword = useRef<string>(undefined);
const passwordInputRef = useRef();
const password = useRef();
const oldPasswordInputRef = useRef();
const oldPassword = useRef();
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
@@ -73,15 +72,7 @@ export const ChangePassword = () => {
throw new Error(strings.backupFailed() + `: ${result.error}`);
}
const passwordChanged = await db.user.changePassword(
oldPassword.current,
password.current
);
if (!passwordChanged) {
throw new Error("Could not change user account password.");
}
await db.user.changePassword(oldPassword.current, password.current);
ToastManager.show({
heading: strings.passwordChangedSuccessfully(),
type: "success",
@@ -94,7 +85,7 @@ export const ChangePassword = () => {
setLoading(false);
ToastManager.show({
heading: strings.passwordChangeFailed(),
message: (e as Error).message,
message: e.message,
type: "error",
context: "local"
});

View File

@@ -21,7 +21,6 @@ import { createRef } from "react";
import { eSendEvent } from "../../services/event-manager";
import { eCloseLoginDialog } from "../../utils/events";
import Navigation from "../../services/navigation";
import { AuthParams } from "../../stores/use-navigation-store";
export const AuthMode = {
login: 0,
signup: 1,
@@ -30,16 +29,15 @@ export const AuthMode = {
trialSignup: 4
};
export const initialAuthMode = createRef<number>();
initialAuthMode.current = AuthMode.login;
export function hideAuth(context?: AuthParams["context"]) {
export const initialAuthMode = createRef(0);
export function hideAuth(context) {
eSendEvent(eCloseLoginDialog);
if (
initialAuthMode.current === AuthMode.welcomeSignup ||
initialAuthMode.current === AuthMode.welcomeLogin ||
context === "intro"
) {
Navigation.navigate("FluidPanelsView", {});
Navigation.replace("FluidPanelsView");
} else {
Navigation.goBack();
}

View File

@@ -0,0 +1,181 @@
/*
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 { View } from "react-native";
import ActionSheet from "react-native-actions-sheet";
import { db } from "../../common/database";
import { DDS } from "../../services/device-detection";
import { ToastManager } from "../../services/event-manager";
import SettingsService from "../../services/settings";
import { useThemeColors } from "@notesnook/theme";
import DialogHeader from "../dialog/dialog-header";
import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
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");
const email = useRef();
const emailInputRef = useRef();
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
const sendRecoveryEmail = async () => {
if (!email.current || error) {
ToastManager.show({
heading: strings.emailRequired(),
type: "error",
context: "local"
});
return;
}
setLoading(true);
try {
let lastRecoveryEmailTime = SettingsService.get().lastRecoveryEmailTime;
if (
lastRecoveryEmailTime &&
Date.now() - JSON.parse(lastRecoveryEmailTime) < 60000 * 3
) {
throw new Error(strings.pleaseWaitBeforeSendEmail());
}
await db.user.recoverAccount(email.current.toLowerCase());
SettingsService.set({
lastRecoveryEmailTime: Date.now()
});
ToastManager.show({
heading: strings.recoveryEmailSent(),
message: strings.recoveryEmailSentDesc(),
type: "success",
context: "local",
duration: 7000
});
setLoading(false);
setSent(true);
} catch (e) {
setLoading(false);
ToastManager.show({
heading: strings.recoveryEmailFailed(),
message: e.message,
type: "error",
context: "local"
});
}
};
return (
<>
<ActionSheet
onBeforeShow={(data) => (email.current = data)}
onClose={() => {
setSent(false);
setLoading(false);
}}
keyboardShouldPersistTaps="always"
onOpen={() => {
emailInputRef.current?.setNativeProps({
text: email.current
});
}}
indicatorStyle={{
width: 100
}}
gestureEnabled
id="forgotpassword_sheet"
>
{sent ? (
<View
style={{
padding: DefaultAppStyles.GAP,
justifyContent: "center",
alignItems: "center",
paddingBottom: 50
}}
>
<IconButton
style={{
width: null,
height: null
}}
color={colors.primary.accent}
name="email"
size={50}
/>
<Heading>{strings.recoveryEmailSent()}</Heading>
<Paragraph
style={{
textAlign: "center"
}}
>
{strings.recoveryEmailSentDesc()}
</Paragraph>
</View>
) : (
<View
style={{
borderRadius: DDS.isTab ? 5 : 0,
backgroundColor: colors.primary.background,
zIndex: 10,
width: "100%",
padding: DefaultAppStyles.GAP
}}
>
<DialogHeader title={strings.accountRecovery()} />
<Seperator />
<Input
fwdRef={emailInputRef}
onChangeText={(value) => {
email.current = value;
}}
defaultValue={email.current}
onErrorCheck={(e) => setError(e)}
returnKeyLabel={strings.next()}
returnKeyType="next"
autoComplete="email"
validationType="email"
autoCorrect={false}
autoCapitalize="none"
errorMessage={strings.emailInvalid()}
placeholder={strings.email()}
onSubmit={() => {}}
/>
<Button
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL,
width: "100%"
}}
loading={loading}
onPress={sendRecoveryEmail}
type="accent"
title={loading ? null : strings.next()}
/>
</View>
)}
</ActionSheet>
</>
);
};

View File

@@ -1,160 +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 React, { useRef, useState } from "react";
import { TextInput, View } from "react-native";
import { db } from "../../common/database";
import { DDS } from "../../services/device-detection";
import { ToastManager } from "../../services/event-manager";
import SettingsService from "../../services/settings";
import { useThemeColors } from "@notesnook/theme";
import DialogHeader from "../dialog/dialog-header";
import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import FormInput, { createFormRef, validators } from "../ui/input/form-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 = ({ userEmail }: { userEmail: string }) => {
const { colors } = useThemeColors("sheet");
const formRef = useRef(
createFormRef({
email: userEmail || ""
})
);
const emailInputRef = useRef<TextInput>(null);
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
const sendRecoveryEmail = async () => {
if (formRef.current.validateField("email")) {
return;
}
const values = formRef.current.getValues();
setLoading(true);
try {
const lastRecoveryEmailTime = SettingsService.get().lastRecoveryEmailTime;
if (
lastRecoveryEmailTime &&
Date.now() - lastRecoveryEmailTime < 60000 * 3
) {
throw new Error(strings.pleaseWaitBeforeSendEmail());
}
await db.user.recoverAccount(values.email.toLowerCase());
SettingsService.set({
lastRecoveryEmailTime: Date.now()
});
ToastManager.show({
heading: strings.recoveryEmailSent(),
message: strings.recoveryEmailSentDesc(),
type: "success",
context: "local",
duration: 7000
});
setLoading(false);
setSent(true);
} catch (e) {
setLoading(false);
formRef.current.setError("email", (e as Error).message);
}
};
return (
<>
{sent ? (
<View
style={{
padding: DefaultAppStyles.GAP,
justifyContent: "center",
alignItems: "center",
paddingBottom: 50
}}
>
<IconButton
style={{
width: null,
height: null
}}
color={colors.primary.accent}
name="email"
size={50}
/>
<Heading>{strings.recoveryEmailSent()}</Heading>
<Paragraph
style={{
textAlign: "center"
}}
>
{strings.recoveryEmailSentDesc()}
</Paragraph>
</View>
) : (
<View
style={{
borderRadius: DDS.isTab ? 5 : 0,
backgroundColor: colors.primary.background,
zIndex: 10,
width: "100%",
padding: DefaultAppStyles.GAP
}}
>
<DialogHeader title={strings.accountRecovery()} />
<Seperator />
<FormInput
name="email"
formRef={formRef}
fwdRef={emailInputRef}
loading={loading}
returnKeyLabel={strings.next()}
returnKeyType="next"
autoComplete="email"
keyboardType="email-address"
autoCorrect={false}
autoCapitalize="none"
placeholder={strings.email()}
validators={[
validators.required(strings.emailRequired()),
validators.email(strings.enterAValidEmailAddress())
]}
onSubmitEditing={() => {
sendRecoveryEmail();
}}
/>
<Button
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL,
width: "100%"
}}
loading={loading}
onPress={sendRecoveryEmail}
type="accent"
title={loading ? null : strings.next()}
/>
</View>
)}
</>
);
};

View File

@@ -23,7 +23,6 @@ import { View } from "react-native";
import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import { hideAuth } from "./common";
import { AuthParams } from "../../stores/use-navigation-store";
export const AuthHeader = (props: { welcome?: boolean }) => {
const { colors } = useThemeColors();
const route = useRoute();
@@ -48,7 +47,7 @@ export const AuthHeader = (props: { welcome?: boolean }) => {
<IconButton
name="arrow-left"
onPress={() => {
hideAuth((route.params as AuthParams)?.context);
hideAuth((route.params as any)?.context);
}}
color={colors.primary.paragraph}
/>

View File

@@ -25,9 +25,8 @@ import { AuthMode, initialAuthMode } from "./common";
import { Login } from "./login";
import { Signup } from "./signup";
import { useThemeColors } from "@notesnook/theme";
import { NavigationProps } from "../../services/navigation";
const Auth = ({ navigation, route }: NavigationProps<"Auth">) => {
const Auth = ({ navigation, route }) => {
const [currentAuthMode, setCurrentAuthMode] = useState(
route?.params?.mode || AuthMode.login
);
@@ -43,10 +42,14 @@ const Auth = ({ navigation, route }: NavigationProps<"Auth">) => {
currentAuthMode !== AuthMode.welcomeLogin ? (
<Signup
changeMode={(mode) => setCurrentAuthMode(mode)}
trial={AuthMode.trialSignup === currentAuthMode}
welcome={initialAuthMode.current === AuthMode.welcomeSignup}
/>
) : (
<Login changeMode={(mode) => setCurrentAuthMode(mode)} />
<Login
welcome={initialAuthMode.current}
changeMode={(mode) => setCurrentAuthMode(mode)}
/>
)}
<Toast context="local" />

View File

@@ -19,17 +19,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import { RouteProp, useRoute } from "@react-navigation/native";
import { useRoute } from "@react-navigation/native";
import React, { useEffect, useState } from "react";
import { TouchableOpacity, View, useWindowDimensions } from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
import { DDS } from "../../services/device-detection";
import { eSendEvent, presentSheet } from "../../services/event-manager";
import { eSendEvent } from "../../services/event-manager";
import Navigation from "../../services/navigation";
import PremiumService from "../../services/premium";
import SettingsService from "../../services/settings";
import Sync from "../../services/sync";
import { RouteParams } from "../../stores/use-navigation-store";
import { useUserStore } from "../../stores/use-user-store";
import { eUserLoggedIn } from "../../utils/events";
import { AppFontSize } from "../../utils/size";
@@ -37,15 +36,15 @@ import { DefaultAppStyles } from "../../utils/styles";
import { sleep } from "../../utils/time";
import { Dialog } from "../dialog";
import { Progress } from "../sheets/progress";
import AppIcon from "../ui/AppIcon";
import { Button } from "../ui/button";
import FormInput, { validators } from "../ui/input/form-input";
import Input from "../ui/input";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { hideAuth } from "./common";
import { ForgotPassword } from "./forgot-password";
import { AuthHeader } from "./header";
import { useLogin } from "./use-login";
import SettingsService from "../../services/settings";
const LoginSteps = {
emailAuth: 1,
@@ -53,24 +52,21 @@ const LoginSteps = {
passwordAuth: 3
};
export const Login = ({
changeMode
}: {
changeMode: (mode: number) => void;
}) => {
export const Login = ({ changeMode }) => {
const { colors } = useThemeColors();
const [focused, setFocused] = useState(false);
const route = useRoute<RouteProp<RouteParams, "Auth">>();
const route = useRoute();
const {
step,
setStep,
password,
email,
emailInputRef,
passwordInputRef,
loading,
setLoading,
login,
error,
formRef
setError,
login
} = useLogin(async () => {
eSendEvent(eUserLoggedIn, true);
await sleep(500);
@@ -84,7 +80,7 @@ export const Login = ({
if (!PremiumService.get() && !SettingsService.getProperty("serverUrls")) {
Navigation.navigate("PayWall", {
context: "signup",
state: route.params?.state,
state: route.params.state,
canGoBack: false
});
} else {
@@ -93,11 +89,6 @@ export const Login = ({
});
const { width, height } = useWindowDimensions();
const isTablet = width > 600;
const onContinue = () => {
login();
};
useEffect(() => {
async () => {
setStep(LoginSteps.emailAuth);
@@ -114,6 +105,7 @@ export const Login = ({
return (
<>
<AuthHeader />
<ForgotPassword />
<Dialog context="two_factor_verify" />
<KeyboardAwareScrollView
style={{
@@ -123,7 +115,7 @@ export const Login = ({
minHeight: "90%"
}}
nestedScrollEnabled
enableAutomaticScroll={true}
enableAutomaticScroll={false}
keyboardShouldPersistTaps="handled"
>
<View
@@ -144,11 +136,11 @@ export const Login = ({
marginBottom: DefaultAppStyles.GAP_VERTICAL,
borderBottomColor: colors.primary.border,
alignSelf: isTablet ? "center" : undefined,
borderWidth: isTablet ? 1 : undefined,
borderColor: isTablet ? colors.primary.border : undefined,
borderRadius: isTablet ? 20 : undefined,
marginTop: isTablet ? 50 : undefined,
width: !isTablet ? undefined : "50%",
borderWidth: isTablet ? 1 : null,
borderColor: isTablet ? colors.primary.border : null,
borderRadius: isTablet ? 20 : null,
marginTop: isTablet ? 50 : null,
width: !isTablet ? null : "50%",
minHeight: height * 0.4
}}
>
@@ -195,35 +187,35 @@ export const Login = ({
? "50%"
: "49.99%"
: focused
? "100%"
: "99.9%",
? "100%"
: "99.9%",
backgroundColor: colors.primary.background,
alignSelf: "center",
paddingHorizontal: DDS.isTab ? 0 : DefaultAppStyles.GAP,
gap: DefaultAppStyles.GAP_VERTICAL
}}
>
<FormInput
name="email"
formRef={formRef}
<Input
fwdRef={emailInputRef}
onChangeText={(value) => {
email.current = value;
}}
testID="input.email"
onErrorCheck={(e) => setError(e)}
returnKeyLabel="Next"
returnKeyType="next"
autoComplete="email"
keyboardType="email-address"
validationType="email"
marginBottom={0}
autoCorrect={false}
autoCapitalize="none"
errorMessage={strings.emailInvalid()}
placeholder={strings.email()}
defaultValue={email.current}
editable={step === LoginSteps.emailAuth && !loading}
validators={[
validators.required(strings.emailRequired()),
validators.email(strings.enterAValidEmailAddress())
]}
onSubmitEditing={() => {
onSubmit={() => {
if (step === LoginSteps.emailAuth) {
onContinue();
login();
} else {
passwordInputRef.current?.focus();
}
@@ -232,10 +224,11 @@ export const Login = ({
{step === LoginSteps.passwordAuth && (
<>
<FormInput
name="password"
formRef={formRef}
<Input
fwdRef={passwordInputRef}
onChangeText={(value) => {
password.current = value;
}}
testID="input.password"
returnKeyLabel={strings.done()}
returnKeyType="done"
@@ -246,10 +239,8 @@ export const Login = ({
placeholder={strings.password()}
marginBottom={0}
editable={!loading}
validators={[validators.required(strings.passwordRequired())]}
onSubmitEditing={() => {
onContinue();
}}
defaultValue={password.current}
onSubmit={() => login()}
/>
<Button
title={strings.forgotPassword()}
@@ -260,13 +251,7 @@ export const Login = ({
}}
onPress={() => {
if (loading) return;
presentSheet({
component: (
<ForgotPassword
userEmail={formRef.current.getValue("email")}
/>
)
});
SheetManager.show("forgotpassword_sheet", email.current);
}}
textStyle={{
textDecorationLine: "underline"
@@ -281,7 +266,8 @@ export const Login = ({
<Button
loading={loading}
onPress={() => {
onContinue();
if (loading) return;
login();
}}
style={{
width: "100%"
@@ -335,25 +321,6 @@ export const Login = ({
</Paragraph>
</TouchableOpacity>
) : null}
{error ? (
<Paragraph
numberOfLines={4}
onPress={() => {}}
color={colors.error.accent}
style={{
textAlign: "center",
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{error.message}
</Paragraph>
) : null}
</View>
</View>
</View>

View File

@@ -43,26 +43,29 @@ import SheetProvider from "../sheet-provider";
import { Toast } from "../toast";
import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
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";
import FormInput, { validators } from "../ui/input/form-input";
export const SessionExpired = () => {
const { colors } = useThemeColors();
const [visible, setVisible] = useState(false);
const [focused, setFocused] = useState(false);
const { step, passwordInputRef, loading, login, formRef } = useLogin(() => {
eSendEvent(eUserLoggedIn, true);
setVisible(false);
setFocused(false);
useUserStore.setState({
disableAppLockRequests: false
});
}, true);
const { step, password, email, passwordInputRef, loading, login } = useLogin(
() => {
eSendEvent(eUserLoggedIn, true);
setVisible(false);
setFocused(false);
useUserStore.setState({
disableAppLockRequests: false
});
},
true
);
const logout = async () => {
try {
@@ -80,28 +83,35 @@ export const SessionExpired = () => {
});
} catch (e) {
ToastManager.show({
heading: (e as Error).message,
heading: e.message,
type: "error",
context: "local"
});
}
};
useEffect(() => {
const sub = eSubscribeEvent(eLoginSessionExpired, open);
return () => {
sub.unsubscribe?.();
};
}, [visible, open]);
const open = React.useCallback(async () => {
try {
const res = await db.tokenManager.getToken();
let res = await db.tokenManager.getToken();
if (!res) throw new Error("no token found");
if (db.tokenManager._isTokenExpired(res))
throw new Error("token expired");
const key = await db.user.getDataEncryptionKeys();
const key = await db.user.getEncryptionKey();
if (!key) throw new Error("No encryption key found.");
Sync.run("global", false, "full", async (complete) => {
if (!complete) {
const user = await db.user.getUser();
let user = await db.user.getUser();
if (!user) return;
formRef.current.setValue("email", user.email);
email.current = user.email;
setVisible(true);
setFocused(false);
return;
@@ -112,23 +122,16 @@ export const SessionExpired = () => {
setVisible(false);
});
} catch (e) {
const user = await db.user.getUser();
let user = await db.user.getUser();
if (!user) return;
formRef.current.setValue("email", user.email);
email.current = user.email;
setFocused(false);
setVisible(true);
useUserStore.setState({
disableAppLockRequests: true
});
}
}, [formRef]);
useEffect(() => {
const sub = eSubscribeEvent(eLoginSessionExpired, open);
return () => {
sub?.unsubscribe?.();
};
}, [visible, open]);
}, [email]);
return (
visible && (
@@ -152,7 +155,6 @@ export const SessionExpired = () => {
enableSheetKeyboardHandler={true}
visible={true}
>
<Dialog context="two_factor_verify" />
<View
style={{
width: focused ? "100%" : "99.9%",
@@ -189,18 +191,16 @@ export const SessionExpired = () => {
textAlign: "center"
}}
>
{strings.sessionExpiredDesc(
getObfuscatedEmail(formRef.current.getValue("email") as string)
)}
{strings.sessionExpiredDesc(getObfuscatedEmail(email.current))}
</Paragraph>
</View>
{step === LoginSteps.passwordAuth ? (
<FormInput
<Input
fwdRef={passwordInputRef}
formRef={formRef}
name="password"
validators={[validators.required(strings.passwordRequired())]}
onChangeText={(value) => {
password.current = value;
}}
returnKeyLabel={strings.done()}
returnKeyType="next"
secureTextEntry
@@ -208,9 +208,7 @@ export const SessionExpired = () => {
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.password()}
onSubmitEditing={() => {
login();
}}
onSubmit={() => login()}
/>
) : null}

View File

@@ -19,11 +19,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import React, { useContext } from "react";
export const SignupContext = React.createContext<{
signup: () => Promise<boolean | undefined>;
signup?: () => Promise<boolean>;
}>({
signup: async () => {
return false;
}
signup: undefined
});
export const useSignupContext = () => useContext(SignupContext);

View File

@@ -19,17 +19,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import { RouteProp, useRoute } from "@react-navigation/native";
import { useRoute } from "@react-navigation/native";
import React, { useRef, useState } from "react";
import {
TextInput,
TouchableOpacity,
View,
useWindowDimensions
} from "react-native";
import { TouchableOpacity, View, useWindowDimensions } from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
import { db } from "../../common/database";
import { DDS } from "../../services/device-detection";
import { ToastManager } from "../../services/event-manager";
import { clearMessage, setEmailVerifyMessage } from "../../services/message";
import Navigation from "../../services/navigation";
import { useUserStore } from "../../stores/use-user-store";
@@ -38,14 +34,12 @@ import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { Loading } from "../loading";
import { Button } from "../ui/button";
import FormInput, { createFormRef, validators } from "../ui/input/form-input";
import Input from "../ui/input";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { AuthHeader } from "./header";
import { SignupContext } from "./signup-context";
import { RouteParams } from "../../stores/use-navigation-store";
import SettingsService from "../../services/settings";
import AppIcon from "../ui/AppIcon";
const SignupSteps = {
signup: 0,
@@ -53,45 +47,47 @@ const SignupSteps = {
createAccount: 2
};
export const Signup = ({
changeMode,
welcome
}: {
changeMode: (mode: number) => void;
welcome: boolean;
}) => {
export const Signup = ({ changeMode, welcome }) => {
const [currentStep, setCurrentStep] = useState(SignupSteps.signup);
const { colors } = useThemeColors();
const formRef = useRef(
createFormRef({
email: "",
password: "",
confirmPassword: ""
})
);
const emailInputRef = useRef<TextInput>(null);
const passwordInputRef = useRef<TextInput>(null);
const confirmPasswordInputRef = useRef<TextInput>(null);
const [errorMessage, setErrorMessage] = useState<string>();
const email = useRef();
const emailInputRef = useRef();
const passwordInputRef = useRef();
const password = useRef();
const confirmPasswordInputRef = useRef();
const confirmPassword = useRef();
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const setUser = useUserStore((state) => state.setUser);
const setLastSynced = useUserStore((state) => state.setLastSynced);
const { width, height } = useWindowDimensions();
const isTablet = width > 600;
const route = useRoute<RouteProp<RouteParams, "Auth">>();
const route = useRoute();
const validateInfo = () => {
if (!password.current || !email.current || !confirmPassword.current) {
ToastManager.show({
heading: strings.allFieldsRequired(),
message: strings.allFieldsRequiredDesc(),
type: "error",
context: "local"
});
return false;
}
return true;
};
const signup = async () => {
setErrorMessage(undefined);
if (!formRef.current.validate()) return;
if (!validateInfo() || error) return;
if (loading) return;
const values = formRef.current.getValues();
setLoading(true);
try {
setCurrentStep(SignupSteps.createAccount);
await db.user.signup(values.email.toLowerCase(), values.password);
const user = await db.user.getUser();
await db.user.signup(email.current.toLowerCase(), password.current);
let user = await db.user.getUser();
setUser(user);
setLastSynced(await db.lastSynced());
clearMessage();
@@ -107,14 +103,12 @@ export const Signup = ({
} catch (e) {
setCurrentStep(SignupSteps.signup);
setLoading(false);
if (
(e as Error).message === "Unable to create an account on this email."
) {
formRef.current.setError("email", (e as Error).message);
} else {
setErrorMessage((e as Error).message);
}
ToastManager.show({
heading: strings.signupFailed(),
message: e.message,
type: "error",
context: "local"
});
return false;
}
};
@@ -136,6 +130,7 @@ export const Signup = ({
minHeight: "90%"
}}
nestedScrollEnabled
enableAutomaticScroll={false}
keyboardShouldPersistTaps="handled"
>
<View
@@ -156,11 +151,11 @@ export const Signup = ({
borderBottomWidth: 0.8,
borderBottomColor: colors.primary.border,
alignSelf: isTablet ? "center" : undefined,
borderWidth: isTablet ? 1 : undefined,
borderColor: isTablet ? colors.primary.border : undefined,
borderRadius: isTablet ? 20 : undefined,
marginTop: isTablet ? 50 : undefined,
width: !isTablet ? undefined : "50%",
borderWidth: isTablet ? 1 : null,
borderColor: isTablet ? colors.primary.border : null,
borderRadius: isTablet ? 20 : null,
marginTop: isTablet ? 50 : null,
width: !isTablet ? null : "50%",
minHeight: height * 0.25
}}
>
@@ -209,55 +204,60 @@ export const Signup = ({
alignSelf: "center"
}}
>
<FormInput
name="email"
formRef={formRef}
<Input
fwdRef={emailInputRef}
loading={loading}
onChangeText={(value) => {
email.current = value;
}}
defaultValue={email.current}
testID="input.email"
onErrorCheck={(e) => setError(e)}
returnKeyLabel="Next"
returnKeyType="next"
autoComplete="email"
keyboardType="email-address"
validationType="email"
autoCorrect={false}
autoCapitalize="none"
errorMessage={strings.emailInvalid()}
placeholder={strings.email()}
blurOnSubmit={false}
validators={[
validators.required(strings.emailRequired()),
validators.email(strings.enterAValidEmailAddress())
]}
onSubmitEditing={() => {
onSubmit={() => {
if (!email.current) return;
passwordInputRef.current?.focus();
}}
/>
<FormInput
name="password"
formRef={formRef}
<Input
fwdRef={passwordInputRef}
loading={loading}
onChangeText={(value) => {
password.current = value;
}}
defaultValue={password.current}
testID="input.password"
onErrorCheck={(e) => setError(e)}
returnKeyLabel="Next"
returnKeyType="next"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
blurOnSubmit={false}
validationType="password"
autoCorrect={false}
placeholder={strings.password()}
validators={[validators.required(strings.passwordRequired())]}
onSubmitEditing={() => {
onSubmit={() => {
if (!password.current) return;
confirmPasswordInputRef.current?.focus();
}}
/>
<FormInput
name="confirmPassword"
formRef={formRef}
<Input
fwdRef={confirmPasswordInputRef}
loading={loading}
onChangeText={(value) => {
confirmPassword.current = value;
}}
defaultValue={confirmPassword.current}
testID="input.confirmPassword"
onErrorCheck={(e) => setError(e)}
returnKeyLabel="Signup"
returnKeyType="done"
secureTextEntry
@@ -265,22 +265,15 @@ export const Signup = ({
autoCapitalize="none"
autoCorrect={false}
blurOnSubmit={false}
validationType="confirmPassword"
customValidator={() => password.current}
placeholder={strings.confirmPassword()}
marginBottom={12}
validators={[
validators.required(strings.confirmPasswordRequired()),
validators.matchField(
"password",
strings.passwordNotMatched()
)
]}
onSubmitEditing={() => {
signup();
}}
onSubmit={signup}
/>
<Button
title={!loading ? strings.continue() : null}
title={!loading ? "Continue" : null}
type="accent"
loading={loading}
onPress={() => {
@@ -314,32 +307,11 @@ export const Signup = ({
</Paragraph>
</Paragraph>
</TouchableOpacity>
{errorMessage ? (
<Paragraph
numberOfLines={4}
onPress={() => {}}
color={colors.error.accent}
style={{
textAlign: "center",
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{errorMessage}
</Paragraph>
) : null}
</View>
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP,
width: DDS.isTab ? "50%" : "100%",
alignSelf: "center"
paddingHorizontal: DefaultAppStyles.GAP
}}
>
<Paragraph
@@ -354,7 +326,7 @@ export const Signup = ({
<Paragraph
size={AppFontSize.xxs}
onPress={() => {
openLinkInBrowser("https://notesnook.com/tos");
openLinkInBrowser("https://notesnook.com/tos", colors);
}}
style={{
textDecorationLine: "underline"
@@ -368,7 +340,10 @@ export const Signup = ({
<Paragraph
size={AppFontSize.xxs}
onPress={() => {
openLinkInBrowser("https://notesnook.com/privacy");
openLinkInBrowser(
"https://notesnook.com/privacy",
colors
);
}}
style={{
textDecorationLine: "underline"

View File

@@ -20,74 +20,37 @@ 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 { TextInput, View } from "react-native";
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 { DefaultAppStyles } from "../../utils/styles";
import { presentDialog } from "../dialog/functions";
import AppIcon from "../ui/AppIcon";
import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import Input from "../ui/input";
import { Pressable } from "../ui/pressable";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { DefaultAppStyles } from "../../utils/styles";
import { presentDialog } from "../dialog/functions";
type MFAInfo = {
primaryMethod: string;
secondaryMethod: string;
token: string;
};
const TwoFactorVerification = ({
onMfaLogin,
mfaInfo,
onCancel
}: {
onMfaLogin: (
login: {
method: string;
code: string;
},
callback: (result: any) => void,
onerror: (e: Error) => void
) => Promise<void>;
mfaInfo: MFAInfo;
onCancel: () => void;
}) => {
const TwoFactorVerification = ({ onMfaLogin, mfaInfo, onCancel }) => {
const { colors } = useThemeColors();
const code = useRef<string>(undefined);
const [currentMethod, setCurrentMethod] = useState<{
isPrimary: boolean;
method: string | null;
}>({
const code = useRef();
const [currentMethod, setCurrentMethod] = useState({
method: mfaInfo?.primaryMethod,
isPrimary: true
});
const { seconds, start, reset, secondsRef } = useTimer(currentMethod.method!);
const { seconds, start, reset } = useTimer(currentMethod.method);
const [loading, setLoading] = useState(false);
const inputRef = useRef<TextInput>(null);
const inputRef = useRef();
const [sending, setSending] = useState(false);
const [error, setError] = useState<Error | undefined>(undefined);
const onNext = async () => {
if (!code.current || code.current.length < 6) {
setError(
new Error("Please provide a valid multi-factor authentication code.")
);
return;
}
if (!currentMethod.method) {
return;
}
if (!code.current || code.current.length < 6) return;
setLoading(true);
setError(undefined);
inputRef.current?.blur();
await onMfaLogin(
{
@@ -99,9 +62,6 @@ const TwoFactorVerification = ({
eSendEvent(eCloseSimpleDialog, "two_factor_verify");
}
setLoading(false);
},
(e) => {
setError(e);
}
);
setLoading(false);
@@ -146,27 +106,25 @@ const TwoFactorVerification = ({
);
};
const onSendCode = useCallback(async () => {
if (secondsRef.current || sending) return;
setSending(true);
try {
await db.mfa.sendCode(currentMethod.method as "sms" | "email");
start(60);
setSending(false);
} catch (e) {
setSending(false);
setError(
new Error(`Error sending 2FA Code. Tap "Send code" to try again `)
);
}
}, [currentMethod.method, secondsRef, sending, start]);
useEffect(() => {
if (currentMethod.method === "sms" || currentMethod.method === "email") {
onSendCode();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentMethod.method]);
}, [currentMethod.method, onSendCode]);
const onSendCode = useCallback(async () => {
if (seconds || sending) return;
// TODO
setSending(true);
try {
await db.mfa.sendCode(currentMethod.method, mfaInfo.token);
start(60);
setSending(false);
} catch (e) {
setSending(false);
ToastManager.error(e, "Error sending 2FA Code", "local");
}
}, [currentMethod.method, mfaInfo.token, seconds, sending, start]);
return (
<ScrollView
@@ -213,11 +171,8 @@ const TwoFactorVerification = ({
textAlign: "center"
}}
>
{currentMethod.method
? strings["2faCodeHelpText"][
currentMethod.method as keyof (typeof strings)["2faCodeHelpText"]
]?.() || strings.select2faCodeHelpText()
: strings.select2faCodeHelpText()}
{strings["2faCodeHelpText"][currentMethod.method]?.() ||
strings.select2faCodeHelpText()}
</Paragraph>
{currentMethod.method === "sms" || currentMethod.method === "email" ? (
@@ -229,7 +184,7 @@ const TwoFactorVerification = ({
? ""
: `${
seconds
? strings.resend2faCode(`${seconds}`)
? strings.resend2faCode(seconds)
: strings.sendCode()
}`
}
@@ -253,15 +208,14 @@ const TwoFactorVerification = ({
fwdRef={inputRef}
textAlign="center"
onChangeText={(value) => {
setError(undefined);
code.current = value;
//onNext();
}}
cursorColor={colors.selected.accent}
selectionHandleColor={colors.selected.accent}
selectionColor={colors.selected.accent}
onSubmitEditing={onNext}
height={60}
marginBottom={0}
inputStyle={{
fontSize: AppFontSize.lg,
textAlign: "center",
@@ -275,26 +229,10 @@ const TwoFactorVerification = ({
containerStyle={{
minWidth: "50%"
}}
wrapperStyle={{
height: 60
}}
/>
{error ? (
<Paragraph
numberOfLines={4}
onPress={() => {}}
color={colors.error.accent}
style={{
textAlign: "center",
marginVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
maxWidth: 250
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{error?.message}
</Paragraph>
) : null}
<Button
title={loading ? null : strings.next()}
@@ -315,7 +253,7 @@ const TwoFactorVerification = ({
<Button
title={strings["2faCodeSecondaryMethodText"][
currentMethod.method as keyof (typeof strings)["2faCodeSecondaryMethodText"]
currentMethod.method
]()}
type="plain"
onPress={onRequestSecondaryMethod}
@@ -369,19 +307,7 @@ const TwoFactorVerification = ({
);
};
TwoFactorVerification.present = (
onMfaLogin: (
login: {
method: string;
code: string;
},
callback: (result: any) => void,
onerror: (e: Error) => void
) => Promise<void>,
data: MFAInfo,
onCancel: () => void,
context?: string
) => {
TwoFactorVerification.present = (onMfaLogin, data, onCancel, context) => {
presentDialog({
component: () => (
<TwoFactorVerification
@@ -391,6 +317,7 @@ TwoFactorVerification.present = (
/>
),
context: context || "two_factor_verify",
disableClosing: true,
transparent: false,
statusBarTranslucent: true
});

View File

@@ -28,7 +28,6 @@ import SettingsService from "../../services/settings";
import { useUserStore } from "../../stores/use-user-store";
import { eCloseSimpleDialog } from "../../utils/events";
import TwoFactorVerification from "./two-factor";
import { createFormRef } from "../ui/input/form-input";
export const LoginSteps = {
emailAuth: 1,
@@ -40,37 +39,49 @@ export const useLogin = (
onFinishLogin?: () => void,
sessionExpired = false
) => {
const [error, setError] = useState<Error>();
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const setUser = useUserStore((state) => state.setUser);
const [step, setStep] = useState(LoginSteps.emailAuth);
const email = useRef<string>();
const password = useRef<string>();
const emailInputRef = useRef<TextInput>(null);
const passwordInputRef = useRef<TextInput>(null);
const formRef = useRef(
createFormRef({
email: "",
password: ""
})
);
const validateInfo = () => {
if (
(!password.current && step === LoginSteps.passwordAuth) ||
(!email.current && step === LoginSteps.emailAuth)
) {
ToastManager.show({
heading: strings.allFieldsRequired(),
message: strings.allFieldsRequiredDesc(),
type: "error",
context: "local"
});
return false;
}
return true;
};
const login = async () => {
if (!validateInfo() || error) return;
try {
if (loading) return;
setError(undefined);
setLoading(true);
switch (step) {
case LoginSteps.emailAuth: {
if (formRef.current.validateField("email")) {
if (!email.current) {
setLoading(false);
return;
}
const mfaInfo = await db.user.authenticateEmail(
formRef.current.getValue("email")
);
const mfaInfo = await db.user.authenticateEmail(email.current);
if (mfaInfo) {
TwoFactorVerification.present(
async (mfa: any, callback: (success: boolean) => void, onerror: (e: Error) => void) => {
async (mfa: any, callback: (success: boolean) => void) => {
try {
const success = await db.user.authenticateMultiFactorCode(
mfa.code,
@@ -92,9 +103,6 @@ export const useLogin = (
eSendEvent(eCloseSimpleDialog, "two_factor_verify");
setLoading(false);
setStep(LoginSteps.emailAuth);
ToastManager.error(new Error("Token expired, try logging in again"));
} else {
onerror(e as Error);
}
}
},
@@ -111,14 +119,13 @@ export const useLogin = (
break;
}
case LoginSteps.passwordAuth: {
if (!formRef.current.validate()) {
if (!email.current || !password.current) {
setLoading(false);
return;
}
const values = formRef.current.getValues();
await db.user.authenticatePassword(
values.email,
values.password,
email.current,
password.current,
undefined,
sessionExpired
);
@@ -135,11 +142,12 @@ export const useLogin = (
const finishWithError = async (e: Error) => {
if (e.message === "invalid_grant") setStep(LoginSteps.emailAuth);
setLoading(false);
if (e.message === "Password is incorrect.") {
formRef.current.setError("password", e.message);
} else {
setError(e);
}
ToastManager.show({
heading: strings.loginFailed(),
message: e.message,
type: "error",
context: "local"
});
};
const finishLogin = async () => {
@@ -166,12 +174,13 @@ export const useLogin = (
login,
step,
setStep,
email,
password,
passwordInputRef,
emailInputRef,
loading,
setLoading,
error,
setError,
formRef
setError
};
};

View File

@@ -36,6 +36,7 @@ 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 useKeyboard from "../../hooks/use-keyboard";
interface FloatingButtonProps {
onPress: () => void;
@@ -119,16 +120,14 @@ const FloatingButton = ({
style={[
{
position: "absolute",
right: position === "right" ? DefaultAppStyles.GAP - 7 : undefined,
left: position === "left" ? DefaultAppStyles.GAP - 7 : undefined,
bottom: 15,
right: position === "right" ? DefaultAppStyles.GAP : undefined,
left: position === "left" ? DefaultAppStyles.GAP : undefined,
bottom: 20,
zIndex: 10,
padding: 15,
...style
},
animatedStyle
]}
pointerEvents="box-none"
>
<TouchableOpacity
testID={testID || notesnook.buttons.add}

View File

@@ -30,7 +30,7 @@ export const Container = ({ children }: PropsWithChildren) => {
const [height, setHeight] = useState(0);
const windowHeightRef = useRef(Dimensions.get("window").height);
const { height: windowHeight } = useWindowDimensions();
const timerRef = useRef<NodeJS.Timeout>(undefined);
const timerRef = useRef<NodeJS.Timeout>();
useEffect(() => {
if (windowHeight !== windowHeightRef.current) {
setHeight(0);

View File

@@ -1,85 +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 React from "react";
import { useThemeColors } from "@notesnook/theme";
import { useRef } from "react";
import { View } from "react-native";
import { defaultBorderRadius } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import DatePicker from "react-native-date-picker";
import dayjs from "dayjs";
import { strings } from "@notesnook/intl";
import { Button } from "../ui/button";
export default function DatePickerComponent(props: {
onConfirm: (date: Date) => void;
onCancel: () => void;
}) {
const { colors, isDark } = useThemeColors();
const dateRef = useRef<Date>(dayjs().add(1, "day").toDate());
return (
<View
style={{
backgroundColor: colors.primary.background,
borderRadius: defaultBorderRadius,
padding: DefaultAppStyles.GAP,
borderWidth: 0.5,
borderColor: colors.primary.border,
width: "80%",
gap: DefaultAppStyles.GAP_VERTICAL
}}
>
<DatePicker
theme={isDark ? "dark" : "light"}
mode="date"
minimumDate={dayjs().add(1, "day").toDate()}
onCancel={() => {
close?.();
}}
date={dateRef.current}
onDateChange={(date) => {
dateRef.current = date;
}}
/>
<Button
title={strings.setExpiry()}
type="accent"
style={{
width: "100%"
}}
onPress={async () => {
if (!dateRef.current) return;
props.onConfirm(dateRef.current);
}}
/>
<Button
title={strings.cancel()}
type="secondary"
style={{
width: "100%"
}}
onPress={async () => {
props.onCancel();
}}
/>
</View>
);
}

View File

@@ -42,7 +42,9 @@ export default function DelayLayout({
...props
}: IDelayLayoutProps) {
const { colors } = useThemeColors();
const loading = useDelayLayout(props.delay === undefined ? 200 : props.delay);
const loading = useDelayLayout(
!props.delay || props.delay < 300 ? 0 : props.delay
);
const Placeholder = placeholder[props.type || "default"];
return loading || props.wait ? (

View File

@@ -20,12 +20,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useThemeColors } from "@notesnook/theme";
import React from "react";
import { AnnouncementDialog } from "../announcements";
import AuthModal from "../auth/auth-modal";
import { SessionExpired } from "../auth/session-expired";
import { Dialog } from "../dialog";
import { AppLockPassword } from "../dialogs/applock-password";
import JumpToSectionDialog from "../dialogs/jump-to-section";
import { LoadingDialog } from "../dialogs/loading";
import PDFPreview from "../dialogs/pdf-preview";
import ResultDialog from "../dialogs/result";
import { VaultDialog } from "../dialogs/vault";
import ImagePreview from "../image-preview";
import MergeConflicts from "../merge-conflicts";
@@ -52,6 +54,7 @@ const DialogProvider = () => {
<>
<MergeConflicts />
<RecoveryKeySheet colors={colors} />
<ResultDialog />
<VaultDialog colors={colors} />
<RateAppSheet />
<ImagePreview />

View File

@@ -0,0 +1,52 @@
/*
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 { eSendEvent } from "../../services/event-manager";
import {
eCloseActionSheet,
eCloseAddNotebookDialog,
eCloseMoveNoteDialog,
eOpenActionSheet,
eOpenAddNotebookDialog,
eOpenMoveNoteDialog
} from "../../utils/events";
export const ActionSheetEvent = (item, buttons) => {
eSendEvent(eOpenActionSheet, {
item,
buttons
});
};
export const ActionSheetHideEvent = () => {
eSendEvent(eCloseActionSheet);
};
export const moveNoteEvent = () => {
eSendEvent(eOpenMoveNoteDialog);
};
export const moveNoteHideEvent = () => {
eSendEvent(eCloseMoveNoteDialog);
};
export const AddNotebookEvent = (notebook) => {
eSendEvent(eOpenAddNotebookDialog, notebook);
};
export const HideAddNotebookEvent = (notebook) => {
eSendEvent(eCloseAddNotebookDialog, notebook);
};

View File

@@ -23,6 +23,7 @@ import {
ColorValue,
KeyboardAvoidingView,
Modal,
Platform,
SafeAreaView,
StyleSheet,
TouchableOpacity,
@@ -135,12 +136,12 @@ const BaseDialog = ({
backgroundColor: background
? background
: transparent
? "transparent"
: "rgba(0,0,0,0.1)"
? "transparent"
: "rgba(0,0,0,0.3)"
}}
>
<KeyboardAvoidingView
enabled={!floating && !avoidKeyboardResize}
enabled={!floating && Platform.OS === "ios" && !avoidKeyboardResize}
behavior="padding"
>
<BouncingView
@@ -154,8 +155,8 @@ const BaseDialog = ({
justifyContent: centered
? "center"
: bottom
? "flex-end"
: "flex-start"
? "flex-end"
: "flex-start"
}
]}
>

View File

@@ -20,7 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React from "react";
import { ActivityIndicator, StyleSheet, View } from "react-native";
import { StyleSheet, View } from "react-native";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { notesnook } from "../../../e2e/test.ids";
import { getColorLinearShade } from "../../utils/colors";
@@ -79,11 +79,6 @@ const DialogButtons = ({
/>
<Paragraph color={colors.primary.accent}>{" " + doneText}</Paragraph>
</View>
) : loading ? (
<ActivityIndicator
size={AppFontSize.lg}
color={colors.primary.accent}
/>
) : (
<View />
)}
@@ -110,6 +105,7 @@ const DialogButtons = ({
style={{
marginLeft: 10
}}
loading={loading}
bold
type={positiveType || "transparent"}
title={positiveTitle}

View File

@@ -22,6 +22,7 @@ import { Text, View, ViewStyle } from "react-native";
import { useThemeColors } from "@notesnook/theme";
import { AppFontSize } from "../../utils/size";
import { Button, ButtonProps } from "../ui/button";
import { PressableProps } from "../ui/pressable";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { DefaultAppStyles } from "../../utils/styles";

View File

@@ -17,12 +17,10 @@ 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 { KeyboardTypeOptions, TextInput } from "react-native";
import { KeyboardTypeOptions } from "react-native";
import { eSendEvent } from "../../services/event-manager";
import { eCloseSimpleDialog, eOpenSimpleDialog } from "../../utils/events";
import { ButtonProps } from "../ui/button";
import { FieldValidator, FormRef } from "../ui/input/form-input";
import { RefObject } from "react";
export type DialogInfo = {
title?: string;
@@ -45,18 +43,6 @@ export type DialogInfo = {
| "errorShade";
icon?: string;
paragraphColor: string;
form?: {
formRef: FormRef;
items: {
name: string;
placeholder: string;
label?: string;
validators: FieldValidator[];
defaultValue?: string;
ref: RefObject<TextInput | null>;
}[];
onFormSubmit?: (form: FormRef) => Promise<boolean>;
};
input: boolean;
inputPlaceholder: string;
defaultValue: string;

View File

@@ -17,14 +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/>.
*/
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, {
useCallback,
useEffect,
useRef,
useState,
RefObject
} from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { TextInput, View, ViewStyle } from "react-native";
import { DDS } from "../../services/device-detection";
import {
@@ -40,7 +35,6 @@ import { sleep } from "../../utils/time";
import { Toast } from "../toast";
import { Button } from "../ui/button";
import Input from "../ui/input";
import { FormInput, type FormRef } from "../ui/input/form-input";
import { Notice } from "../ui/notice";
import Seperator from "../ui/seperator";
import BaseDialog from "./base-dialog";
@@ -60,38 +54,15 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
});
const inputRef = useRef<TextInput>(null);
const [dialogInfo, setDialogInfo] = useState<DialogInfo>();
const formRef = useRef(dialogInfo?.form?.formRef);
formRef.current = dialogInfo?.form?.formRef;
const onPressPositive = async () => {
// Handle form submission if form is available
if (dialogInfo?.form && formRef.current) {
inputRef.current?.blur();
try {
const isValid = await formRef.current.validate();
if (!isValid) {
return;
}
if (dialogInfo.form.onFormSubmit) {
setLoading(true);
const result = await dialogInfo.form.onFormSubmit(formRef.current);
if (result === false) {
setLoading(false);
return;
}
}
} catch (e) {
/** Empty */
}
setLoading(false);
} else if (dialogInfo?.positivePress) {
// Handle old input-based submission
if (dialogInfo?.positivePress) {
inputRef.current?.blur();
setLoading(true);
let result = false;
try {
result = await dialogInfo.positivePress(
values.current.inputValue,
values.current.inputValue || dialogInfo.defaultValue,
checked
);
} catch (e) {
@@ -106,7 +77,6 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
setChecked(false);
values.current.inputValue = undefined;
formRef.current = undefined;
setVisible(false);
};
@@ -116,7 +86,6 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
if (data.context !== context) return;
setDialogInfo(data);
setChecked(data.check?.defaultValue);
formRef.current = data?.form?.formRef;
values.current.inputValue = data.defaultValue;
setVisible(true);
},
@@ -126,7 +95,6 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
const hide = React.useCallback(() => {
setChecked(false);
values.current.inputValue = undefined;
formRef.current = undefined;
setVisible(false);
setDialogInfo(undefined);
dialogInfo?.onClose?.();
@@ -167,30 +135,19 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
? false
: dialogInfo.statusBarTranslucent
}
bounce={!dialogInfo.input && !dialogInfo.form}
bounce={!dialogInfo.input}
closeOnTouch={!dialogInfo.disableBackdropClosing}
background={dialogInfo.background}
transparent={
dialogInfo.transparent === undefined ? false : dialogInfo.transparent
dialogInfo.transparent === undefined ? true : dialogInfo.transparent
}
onShow={async () => {
if (dialogInfo.input && !dialogInfo.form) {
if (dialogInfo.input) {
inputRef.current?.setNativeProps({
text: dialogInfo.defaultValue
});
await sleep(300);
inputRef.current?.focus();
} else if (dialogInfo.form) {
const items = dialogInfo.form?.items;
const firstItem = items[0];
for (const item of items) {
if (item.defaultValue) {
item.ref.current?.setNativeProps({
text: dialogInfo.defaultValue
});
}
}
firstItem?.ref?.current?.focus();
}
}}
visible={true}
@@ -214,36 +171,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
/>
<Seperator half />
{dialogInfo.form ? (
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP,
gap: DefaultAppStyles.GAP / 2
}}
>
{dialogInfo.form.items.map((item, index) => (
<FormInput
key={item.name}
fwdRef={item.ref}
name={item.name}
autoFocus={index === 0}
placeholder={item.placeholder}
formRef={formRef as RefObject<FormRef>}
validators={item.validators}
defaultValue={item.defaultValue}
secureTextEntry={dialogInfo.secureTextEntry}
onSubmitEditing={() => {
const nextItem = dialogInfo?.form?.items?.[index + 1];
if (nextItem) {
nextItem?.ref.current?.focus();
} else {
onPressPositive();
}
}}
/>
))}
</View>
) : dialogInfo.input ? (
{dialogInfo.input ? (
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP
@@ -257,7 +185,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
}}
testID="input-value"
secureTextEntry={dialogInfo.secureTextEntry}
defaultValue={dialogInfo.defaultValue}
//defaultValue={dialogInfo.defaultValue}
onSubmit={() => {
onPressPositive();
}}
@@ -310,10 +238,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
<DialogButtons
onPressNegative={onNegativePress}
onPressPositive={
(dialogInfo.positivePress || dialogInfo.form?.onFormSubmit) &&
onPressPositive
}
onPressPositive={dialogInfo.positivePress && onPressPositive}
loading={loading}
positiveTitle={dialogInfo.positiveText}
negativeTitle={dialogInfo.negativeText}

View File

@@ -201,14 +201,12 @@ export const AppLockPassword = () => {
accountPass
? strings.enterAccountPassword()
: mode === "change"
? keyboardType === "pin"
? strings.newPin()
: strings.newPassword()
: `${
keyboardType === "pin"
? strings.pin()
: strings.password()
}`
? keyboardType === "pin"
? strings.newPin()
: strings.newPassword()
: `${
keyboardType === "pin" ? strings.pin() : strings.password()
}`
}
/>
@@ -366,9 +364,7 @@ export const AppLockPassword = () => {
SettingsService.getProperty("biometricsAuthEnabled") === false
) {
SettingsService.setProperty("appLockEnabled", false);
SettingsService.setPrivacyScreen(
SettingsService.getProperty("privacyScreen")
);
SettingsService.setPrivacyScreen(SettingsService.get());
ToastManager.show({
message: strings.applockDisabled(),
type: "success"
@@ -387,8 +383,8 @@ export const AppLockPassword = () => {
mode === "remove"
? strings.remove()
: mode === "change"
? strings.change()
: strings.save()
? strings.change()
: strings.save()
}
negativeTitle={strings.cancel()}
positiveType="transparent"

View File

@@ -51,7 +51,7 @@ const ColorPicker = ({
const [selectedColor, setSelectedColor] = useState<string>();
const { colors } = useThemeColors();
const inputRef = useRef<TextInput>(null);
const title = useRef<string>(undefined);
const title = useRef<string>();
const colorRef = useStoredRef("color-ref", "#f0f0f0");
return (
@@ -167,16 +167,14 @@ const ColorPicker = ({
);
if (!title.current)
return ToastManager.error(
new Error(strings.allFieldsRequired()),
"color-picker"
new Error(strings.allFieldsRequired())
);
const exists = await db.colors.all.find((v) =>
v.and([v(`colorCode`, "==", selectedColor)])
);
if (exists)
return ToastManager.error(
new Error(strings.colorExists(selectedColor)),
"color-picker"
new Error(strings.colorExists(selectedColor))
);
const id = await db.colors.add({
title: title.current,

View File

@@ -46,7 +46,7 @@ import Paragraph from "../../ui/typography/paragraph";
import { DefaultAppStyles } from "../../../utils/styles";
const JumpToSectionDialog = () => {
const scrollRef = useRef<RefObject<FlatList>>(undefined);
const scrollRef = useRef<RefObject<FlatList>>();
const [data, setData] = useState<VirtualizedGrouping<Item>>();
const { colors } = useThemeColors();
const notes = data;

View File

@@ -23,7 +23,6 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
import { Dimensions, TextInput, View } from "react-native";
import Orientation from "react-native-orientation-locker";
import Pdf from "react-native-pdf";
import FileViewer from "react-native-file-viewer";
import { MMKV } from "../../../common/database/mmkv";
import downloadAttachment from "../../../common/filesystem/download-attachment";
import { deleteCacheFileByPath, exists } from "../../../common/filesystem/io";
@@ -44,7 +43,6 @@ import SheetProvider from "../../sheet-provider";
import { IconButton } from "../../ui/icon-button";
import { ProgressBarComponent } from "../../ui/svg/lazy";
import Paragraph from "../../ui/typography/paragraph";
import ReactNativeBlobUtil from "react-native-blob-util";
const WIN_WIDTH = Dimensions.get("window").width;
const WIN_HEIGHT = Dimensions.get("window").height;
@@ -119,11 +117,8 @@ const PDFPreview = () => {
setVisible(false);
return;
}
let path = `${cacheDir}/${attachment.filename}`;
const path = `${cacheDir}/${uri}`;
snapshotValue.current = snapshot.current;
await ReactNativeBlobUtil.fs
.mv(`${cacheDir}/${uri}`, path)
.catch(console.log);
setPDFSource("file://" + path);
setLoading(false);
}, 100);
@@ -132,7 +127,7 @@ const PDFPreview = () => {
);
const close = () => {
deleteCacheFileByPath(pdfSource.replace("file://", ""));
deleteCacheFileByPath(pdfSource);
setPDFSource(null);
setVisible(false);
setPassword("");
@@ -162,12 +157,7 @@ const PDFPreview = () => {
return (
visible && (
<BaseDialog
animation="fade"
visible={true}
onRequestClose={close}
useSafeArea={false}
>
<BaseDialog animation="fade" visible={true} onRequestClose={close}>
<SheetProvider context={attachment?.hash} />
<Dialog context={attachment?.hash} />
@@ -233,48 +223,45 @@ const PDFPreview = () => {
<View
style={{
flexDirection: "row",
gap: DefaultAppStyles.GAP_SMALL
alignItems: "center",
marginRight: 12
}}
>
<View
<TextInput
ref={inputRef}
defaultValue={currentPage + ""}
style={{
flexDirection: "row",
alignItems: "center"
color: colors.primary.paragraph,
padding: 0,
paddingTop: 0,
paddingBottom: 0,
marginTop: 0,
marginBottom: 0,
paddingVertical: 0,
height: 25,
backgroundColor: colors.secondary.background,
width: 40,
textAlign: "center",
marginRight: 4,
borderRadius: 3,
fontFamily: "Inter-Regular"
}}
>
<TextInput
ref={inputRef}
defaultValue={currentPage + ""}
style={{
color: colors.primary.paragraph,
padding: 0,
paddingTop: 0,
paddingBottom: 0,
marginTop: 0,
marginBottom: 0,
paddingVertical: 0,
height: 25,
backgroundColor: colors.secondary.background,
width: 40,
textAlign: "center",
marginRight: 4,
borderRadius: 3,
fontFamily: "Inter-Regular"
}}
selectTextOnFocus
keyboardType="decimal-pad"
onSubmitEditing={(event) => {
setCurrentPage(event.nativeEvent.text);
pdfRef.current?.setPage(
parseInt(event.nativeEvent.text)
);
}}
blurOnSubmit
/>
<Paragraph color={colors.static.white}>
/{numPages}
</Paragraph>
</View>
selectTextOnFocus
keyboardType="decimal-pad"
onSubmitEditing={(event) => {
setCurrentPage(event.nativeEvent.text);
pdfRef.current?.setPage(parseInt(event.nativeEvent.text));
}}
blurOnSubmit
/>
<Paragraph color={colors.static.white}>/{numPages}</Paragraph>
</View>
<View
style={{
flexDirection: "row"
}}
>
<IconButton
color={colors.static.white}
name="download"
@@ -282,16 +269,6 @@ const PDFPreview = () => {
downloadAttachment(attachment.hash, false);
}}
/>
<IconButton
color={colors.static.white}
name="open-in-new"
onPress={() => {
FileViewer.open(pdfSource, {
showOpenWithDialog: true,
showAppsSuggestions: true
});
}}
/>
</View>
</View>
{pdfSource ? (

View File

@@ -50,7 +50,7 @@ export default function Progress() {
const { colors } = useThemeColors();
const [progress, setProgress] = React.useState<string | undefined>();
const [visible, setVisible] = React.useState(false);
const cancelCallback = React.useRef<() => void>(undefined);
const cancelCallback = React.useRef<() => void>();
const [data, setData] = React.useState<{
title?: string;
paragraph?: string;

View File

@@ -0,0 +1,145 @@
/*
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, { useEffect, useState } from "react";
import { View } from "react-native";
import { DDS } from "../../../services/device-detection";
import {
eSubscribeEvent,
eUnSubscribeEvent
} from "../../../services/event-manager";
import { useThemeColors } from "@notesnook/theme";
import { getElevationStyle } from "../../../utils/elevation";
import { eCloseResultDialog, eOpenResultDialog } from "../../../utils/events";
import { AppFontSize } from "../../../utils/size";
import BaseDialog from "../../dialog/base-dialog";
import { Button } from "../../ui/button";
import Seperator from "../../ui/seperator";
import Heading from "../../ui/typography/heading";
import Paragraph from "../../ui/typography/paragraph";
import { ProFeatures } from "./pro-features";
import { DefaultAppStyles } from "../../../utils/styles";
const ResultDialog = () => {
const { colors } = useThemeColors();
const [visible, setVisible] = useState(false);
const [dialogData, setDialogData] = useState({
title: "",
paragraph: "",
button: ""
});
useEffect(() => {
eSubscribeEvent(eOpenResultDialog, open);
eSubscribeEvent(eCloseResultDialog, close);
return () => {
eUnSubscribeEvent(eOpenResultDialog, open);
eUnSubscribeEvent(eCloseResultDialog, close);
};
}, []);
const open = (data) => {
if (data) {
setDialogData(data);
}
setVisible(true);
};
const close = () => {
setVisible(false);
};
return !visible ? null : (
<BaseDialog visible={true} onRequestClose={close}>
<View
style={{
...getElevationStyle(5),
width: DDS.isTab ? 350 : "85%",
maxHeight: 500,
borderRadius: 10,
backgroundColor: colors.primary.background,
paddingTop: 20,
justifyContent: "center",
alignItems: "center"
}}
>
<Heading
size={AppFontSize.lg}
textBreakStrategy="balanced"
style={{
alignSelf: "center",
textAlign: "center",
marginTop: DefaultAppStyles.GAP_VERTICAL,
maxWidth: "100%",
marginBottom: DefaultAppStyles.GAP_VERTICAL,
paddingHorizontal: DefaultAppStyles.GAP
}}
>
{dialogData.title}
</Heading>
<Paragraph
color={colors.secondary.paragraph}
size={AppFontSize.md}
style={{
alignSelf: "center",
textAlign: "center",
maxWidth: "80%",
lineHeight: AppFontSize.sm + 5
}}
>
{dialogData.paragraph}
</Paragraph>
<Seperator />
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP,
alignItems: "center",
width: "100%"
}}
>
<ProFeatures count={4} />
</View>
<Seperator />
<View
style={{
backgroundColor: colors.secondary.background,
width: "100%",
borderBottomRightRadius: 10,
borderBottomLeftRadius: 10,
paddingVertical: DefaultAppStyles.GAP_VERTICAL
}}
>
<Button
title={dialogData.button}
width={null}
style={{
paddingHorizontal: DefaultAppStyles.GAP
}}
onPress={close}
/>
</View>
</View>
</BaseDialog>
);
};
export default ResultDialog;

View File

@@ -0,0 +1,104 @@
/*
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 from "react";
import { View } from "react-native";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { eSendEvent } from "../../../services/event-manager";
import { useThemeColors } from "@notesnook/theme";
import {
eCloseSheet,
eCloseResultDialog,
eOpenPremiumDialog
} from "../../../utils/events";
import { defaultBorderRadius, AppFontSize } from "../../../utils/size";
import { sleep } from "../../../utils/time";
import Paragraph from "../../ui/typography/paragraph";
import { DefaultAppStyles } from "../../../utils/styles";
export const ProFeatures = ({ count = 6 }) => {
const { colors } = useThemeColors();
return (
<>
{[
{
content:
"Unlock unlimited notebooks, tags, colors. Organize like a pro"
},
{
content:
"Attach files upto 500MB, upload 4K images with unlimited storage"
},
{
content: "Instantly sync to unlimited devices"
},
{
content: "A private vault to keep everything important always locked"
},
{
content:
"Rich note editing experience with markdown, tables, checklists and more"
},
{
content: "Export your notes in Pdf, markdown and html formats"
}
]
.slice(0, count)
.map((item) => (
<View
key={item.content}
style={{
flexDirection: "row",
width: "100%",
height: 40,
paddingHorizontal: 0,
marginBottom: DefaultAppStyles.GAP_VERTICAL,
alignItems: "center",
borderRadius: defaultBorderRadius,
justifyContent: "flex-start"
}}
>
<Icon
size={AppFontSize.lg}
color={colors.primary.accent}
name="check"
/>
<Paragraph style={{ marginLeft: 5, flexShrink: 1 }}>
{item.content}
</Paragraph>
</View>
))}
<Paragraph
onPress={async () => {
eSendEvent(eCloseResultDialog);
eSendEvent(eCloseSheet);
await sleep(300);
eSendEvent(eOpenPremiumDialog);
}}
size={AppFontSize.xs}
style={{
textDecorationLine: "underline",
color: colors.secondary.paragraph
}}
>
See all features included in Notesnook Pro
</Paragraph>
</>
);
};

View File

@@ -0,0 +1,902 @@
/*
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 Clipboard from "@react-native-clipboard/clipboard";
import React, { Component, createRef } from "react";
import { InteractionManager, View } from "react-native";
import Share from "react-native-share";
import { notesnook } from "../../../../e2e/test.ids";
import { db } from "../../../common/database";
import BiometricService from "../../../services/biometrics";
import { DDS } from "../../../services/device-detection";
import {
ToastManager,
eSendEvent,
eSubscribeEvent,
eUnSubscribeEvent
} from "../../../services/event-manager";
import Navigation from "../../../services/navigation";
import { getElevationStyle } from "../../../utils/elevation";
import {
eCloseActionSheet,
eCloseVaultDialog,
eOnLoadNote,
eOpenVaultDialog,
eUpdateNoteInEditor
} from "../../../utils/events";
import { deleteItems } from "../../../utils/functions";
import { fluidTabsRef } from "../../../utils/global-refs";
import { convertNoteToText } from "../../../utils/note-to-text";
import { sleep } from "../../../utils/time";
import BaseDialog from "../../dialog/base-dialog";
import DialogButtons from "../../dialog/dialog-buttons";
import DialogHeader from "../../dialog/dialog-header";
import { Toast } from "../../toast";
import { Button } from "../../ui/button";
import Input from "../../ui/input";
import Seperator from "../../ui/seperator";
import Paragraph from "../../ui/typography/paragraph";
import { strings } from "@notesnook/intl";
import { DefaultAppStyles } from "../../../utils/styles";
export class VaultDialog extends Component {
constructor(props) {
super(props);
this.state = {
visible: false,
wrongPassword: false,
loading: false,
note: {},
vault: false,
locked: true,
permanant: false,
goToEditor: false,
share: false,
passwordsDontMatch: false,
deleteNote: false,
focusIndex: null,
biometricUnlock: false,
isBiometryEnrolled: false,
isBiometryAvailable: false,
fingerprintAccess: false,
changePassword: false,
copyNote: false,
revokeFingerprintAccess: false,
title: strings.goToEditor(),
description: null,
clearVault: false,
deleteVault: false,
deleteAll: false,
noteLocked: false
};
this.passInputRef = createRef();
this.confirmPassRef = createRef();
this.changePassInputRef = createRef();
this.password = null;
this.confirmPassword = null;
this.newPassword = null;
this.title = !this.state.novault
? strings.createVault()
: this.state.fingerprintAccess
? strings.vaultFingerprintUnlock()
: this.state.revokeFingerprintAccess
? strings.revokeVaultFingerprintUnlock()
: this.state.changePassword
? strings.changeVaultPassword()
: this.state.noteLocked
? this.state.deleteNote
? strings.deleteNote()
: this.state.share
? strings.shareNote()
: this.state.copyNote
? strings.copyNote()
: this.state.goToEditor
? strings.goToEditor()
: strings.goToEditor()
: strings.lockNote();
}
componentDidMount() {
eSubscribeEvent(eOpenVaultDialog, this.open);
eSubscribeEvent(eCloseVaultDialog, this.close);
}
componentWillUnmount() {
eUnSubscribeEvent(eOpenVaultDialog, this.open);
eUnSubscribeEvent(eCloseVaultDialog, this.close);
}
/**
*
* @param {import('../../../services/event-manager').vaultType} data
*/
open = async (data) => {
let biometry = await BiometricService.isBiometryAvailable();
let available = false;
let fingerprint = await BiometricService.hasInternetCredentials("nn_vault");
if (biometry) {
available = true;
}
this.setState({
note: data.item,
novault: data.novault,
locked: data.locked,
permanant: data.permanant,
goToEditor: data.goToEditor,
share: data.share,
deleteNote: data.deleteNote,
copyNote: data.copyNote,
isBiometryAvailable: available,
biometricUnlock: fingerprint,
isBiometryEnrolled: fingerprint,
fingerprintAccess: data.fingerprintAccess,
changePassword: data.changePassword,
revokeFingerprintAccess: data.revokeFingerprintAccess,
title: data.title,
description: data.description,
clearVault: data.clearVault,
deleteVault: data.deleteVault,
noteLocked: data.item && (await db.vaults.itemExists(data.item))
});
if (
fingerprint &&
data.novault &&
!data.fingerprintAccess &&
!data.revokeFingerprintAccess &&
!data.changePassword &&
!data.clearVault &&
!data.deleteVault
) {
await this._onPressFingerprintAuth(data.title, data.description);
} else {
this.setState({
visible: true
});
}
};
close = () => {
if (this.state.loading) {
ToastManager.show({
heading: this.state.title,
message: strings.pleaseWait() + "...",
type: "success",
context: "local"
});
return;
}
Navigation.queueRoutesForUpdate();
this.password = null;
this.confirmPassword = null;
this.setState({
visible: false,
note: {},
locked: false,
permanant: false,
goToEditor: false,
share: false,
novault: false,
deleteNote: false,
passwordsDontMatch: false
});
};
onPress = async () => {
if (this.state.revokeFingerprintAccess) {
await this._revokeFingerprintAccess();
this.close();
return;
}
if (this.state.loading) return;
if (!this.password) {
ToastManager.show({
heading: strings.passwordNotEntered(),
type: "error",
context: "local"
});
return;
}
if (!this.state.novault) {
if (this.password !== this.confirmPassword) {
ToastManager.show({
heading: strings.passwordNotMatched(),
type: "error",
context: "local"
});
this.setState({
passwordsDontMatch: true
});
return;
}
this._createVault();
} else if (this.state.changePassword) {
this.setState({
loading: true
});
db.vault
.changePassword(this.password, this.newPassword)
.then(() => {
this.setState({
loading: false
});
if (this.state.biometricUnlock) {
this._enrollFingerprint(this.newPassword);
}
ToastManager.show({
heading: strings.passwordUpdated(),
type: "success",
context: "global"
});
this.close();
})
.catch((e) => {
this.setState({
loading: false
});
if (e.message === db.vault.ERRORS.wrongPassword) {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
} else {
ToastManager.error(e);
}
});
} else if (this.state.locked) {
if (!this.password || this.password.trim() === 0) {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
this.setState({
wrongPassword: true
});
return;
}
if (this.state.noteLocked) {
await this._unlockNote();
} else {
db.vault
.unlock(this.password)
.then(async () => {
this.setState({
wrongPassword: false
});
await this._lockNote();
})
.catch((e) => {
this._takeErrorAction(e);
});
}
} else if (this.state.fingerprintAccess) {
this._enrollFingerprint(this.password);
} else if (this.state.clearVault) {
await this.clearVault();
} else if (this.state.deleteVault) {
await this.deleteVault();
}
};
deleteVault = async () => {
this.setState({
loading: true
});
try {
let verified = true;
if (await db.user.getUser()) {
verified = await db.user.verifyPassword(this.password);
}
if (verified) {
let noteIds = [];
if (this.state.deleteAll) {
const vault = await db.vaults.default();
const relations = await db.relations.from(vault, "note").get();
noteIds = relations.map((item) => item.toId);
}
await db.vault.delete(this.state.deleteAll);
if (this.state.deleteAll) {
noteIds.forEach((id) => {
eSendEvent(
eUpdateNoteInEditor,
{
id: id,
deleted: true
},
true
);
});
}
eSendEvent("vaultUpdated");
this.setState({
loading: false
});
this.close();
} else {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
}
} catch (e) {
console.error(e);
}
this.setState({
loading: false
});
};
clearVault = async () => {
this.setState({
loading: true
});
try {
const vault = await db.vaults.default();
const relations = await db.relations.from(vault, "note").get();
const noteIds = relations.map((item) => item.toId);
await db.vault.clear(this.password);
noteIds.forEach((id) => {
eSendEvent(
eUpdateNoteInEditor,
{
id: id,
deleted: true
},
true
);
});
this.setState({
loading: false
});
this.close();
eSendEvent("vaultUpdated");
} catch (e) {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
}
this.setState({
loading: false
});
};
async _lockNote() {
if (!this.password || this.password.trim() === 0) {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
return;
} else {
await db.vault.add(this.state.note.id);
eSendEvent(eUpdateNoteInEditor, this.state.note, true);
this.close();
ToastManager.show({
message: strings.noteLocked(),
type: "error",
context: "local"
});
this.setState({
loading: false
});
}
}
async _unlockNote() {
if (!this.password || this.password.trim() === 0) {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
return;
}
if (this.state.permanant) {
this._permanantUnlock();
} else {
await this._openNote();
}
}
_openNote = async () => {
try {
let note = await db.vault.open(this.state.note.id, this.password);
if (this.state.biometricUnlock && !this.state.isBiometryEnrolled) {
await this._enrollFingerprint(this.password);
}
if (this.state.goToEditor) {
this._openInEditor(note);
} else if (this.state.share) {
await this._shareNote(note);
} else if (this.state.deleteNote) {
await this._deleteNote();
} else if (this.state.copyNote) {
await this._copyNote(note);
}
} catch (e) {
this._takeErrorAction(e);
}
};
async _deleteNote() {
try {
await db.vault.remove(this.state.note.id, this.password);
await deleteItems("note", [this.state.note.id]);
this.close();
} catch (e) {
this._takeErrorAction(e);
}
}
async _enrollFingerprint(password) {
this.setState(
{
loading: true
},
async () => {
try {
await db.vault.unlock(password);
await BiometricService.storeCredentials(password);
this.setState({
loading: false
});
eSendEvent("vaultUpdated");
ToastManager.show({
heading: strings.biometricUnlockEnabled(),
type: "success",
context: "global"
});
this.close();
} catch (e) {
this.close();
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
this.setState({
loading: false
});
}
}
);
}
async _createVault() {
await db.vault.create(this.password);
if (this.state.biometricUnlock) {
await this._enrollFingerprint(this.password);
}
if (this.state.note?.id) {
await db.vault.add(this.state.note.id);
eSendEvent(eUpdateNoteInEditor, this.state.note, true);
this.setState({
loading: false
});
ToastManager.show({
heading: strings.noteLocked(),
type: "success",
context: "global"
});
this.close();
} else {
ToastManager.show({
heading: strings.vaultCreated(),
type: "success",
context: "global"
});
this.close();
}
eSendEvent("vaultUpdated");
}
_permanantUnlock() {
db.vault
.remove(this.state.note.id, this.password)
.then(() => {
ToastManager.show({
heading: strings.noteUnlocked(),
type: "success",
context: "global"
});
eSendEvent(eUpdateNoteInEditor, this.state.note, true);
this.close();
})
.catch((e) => {
this._takeErrorAction(e);
});
}
_openInEditor(note) {
this.close();
InteractionManager.runAfterInteractions(async () => {
eSendEvent(eOnLoadNote, {
item: note
});
if (!DDS.isTab) {
fluidTabsRef.current?.goToPage("editor");
}
});
}
async _copyNote(note) {
Clipboard.setString((await convertNoteToText(note, true)) || "");
ToastManager.show({
heading: strings.noteCopied(),
type: "success",
context: "global"
});
this.close();
}
async _shareNote(note) {
this.close();
try {
await Share.open({
heading: note.title,
failOnCancel: false,
message: (await convertNoteToText(note)) || ""
});
} catch (e) {
console.error(e);
}
}
_takeErrorAction(e) {
this.setState({
wrongPassword: true,
visible: true
});
setTimeout(() => {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
}, 500);
}
_revokeFingerprintAccess = async () => {
try {
await BiometricService.resetCredentials();
eSendEvent("vaultUpdated");
ToastManager.show({
heading: strings.biometricUnlockDisabled(),
type: "success",
context: "global"
});
} catch (e) {
ToastManager.show({
heading: e.message,
type: "success",
context: "global"
});
}
};
_onPressFingerprintAuth = async (title, description) => {
try {
let credentials = await BiometricService.getCredentials(
title || this.state.title,
description || this.state.description
);
if (credentials?.password) {
this.password = credentials.password;
this.onPress();
} else {
eSendEvent(eCloseActionSheet);
await sleep(300);
this.setState({
visible: true
});
}
} catch (e) {
console.error(e);
}
};
render() {
const { colors } = this.props;
const {
note,
visible,
novault,
deleteNote,
share,
goToEditor,
fingerprintAccess,
changePassword,
loading,
deleteVault,
clearVault
} = this.state;
if (!visible) return null;
return (
<BaseDialog
onShow={async () => {
await sleep(100);
this.passInputRef.current?.focus();
}}
statusBarTranslucent={false}
onRequestClose={this.close}
visible={true}
>
<View
style={{
...getElevationStyle(5),
width: DDS.isTab ? 350 : "85%",
borderRadius: 10,
backgroundColor: colors.primary.background,
paddingTop: 12
}}
>
<DialogHeader title={this.state.title} icon="shield" padding={12} />
<Seperator half />
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP
}}
>
{(novault ||
changePassword ||
this.state.clearVault ||
this.state.deleteVault) &&
!this.state.revokeFingerprintAccess ? (
<>
<Input
fwdRef={this.passInputRef}
editable={!loading}
autoCapitalize="none"
testID={notesnook.ids.dialogs.vault.pwd}
onChangeText={(value) => {
this.password = value;
}}
marginBottom={
!this.state.biometricUnlock ||
!this.state.isBiometryEnrolled ||
!novault ||
changePassword
? 0
: 10
}
onSubmit={() => {
changePassword
? this.confirmPassRef.current?.focus()
: this.onPress;
}}
autoComplete="password"
returnKeyLabel={
changePassword ? strings.next() : this.state.title
}
returnKeyType={changePassword ? "next" : "done"}
secureTextEntry
placeholder={
changePassword
? strings.currentPassword()
: strings.password()
}
/>
{!this.state.biometricUnlock ||
!this.state.isBiometryEnrolled ||
!novault ||
changePassword ? null : (
<Button
onPress={() =>
this._onPressFingerprintAuth(strings.unlockNote(), "")
}
icon="fingerprint"
width="100%"
title={strings.unlockWithBiometrics()}
type="transparent"
/>
)}
</>
) : null}
{this.state.deleteVault && (
<Button
onPress={() =>
this.setState({
deleteAll: !this.state.deleteAll
})
}
icon={
this.state.deleteAll
? "check-circle-outline"
: "checkbox-blank-circle-outline"
}
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
width="100%"
title={strings.deleteAllNotes()}
type="errorShade"
/>
)}
{changePassword ? (
<>
<Seperator half />
<Input
ref={this.confirmPassRef}
editable={!loading}
testID={notesnook.ids.dialogs.vault.changePwd}
autoCapitalize="none"
onChangeText={(value) => {
this.newPassword = value;
}}
autoComplete="password"
onSubmit={this.onPress}
returnKeyLabel="Change"
returnKeyType="done"
secureTextEntry
placeholder={strings.newPassword()}
/>
</>
) : null}
{!novault ? (
<View>
<Input
fwdRef={this.passInputRef}
autoCapitalize="none"
testID={notesnook.ids.dialogs.vault.pwd}
onChangeText={(value) => {
this.password = value;
}}
autoComplete="password"
returnKeyLabel={strings.next()}
returnKeyType="next"
secureTextEntry
onSubmit={() => {
this.confirmPassRef.current?.focus();
}}
placeholder={strings.password()}
/>
<Input
fwdRef={this.confirmPassRef}
autoCapitalize="none"
testID={notesnook.ids.dialogs.vault.pwdAlt}
secureTextEntry
validationType="confirmPassword"
customValidator={() => this.password}
errorMessage="Passwords do not match."
onErrorCheck={() => null}
marginBottom={0}
autoComplete="password"
returnKeyLabel="Create"
returnKeyType="done"
onChangeText={(value) => {
this.confirmPassword = value;
if (value !== this.password) {
this.setState({
passwordsDontMatch: true
});
} else {
this.setState({
passwordsDontMatch: false
});
}
}}
onSubmit={this.onPress}
placeholder={strings.confirmPassword()}
/>
</View>
) : null}
{this.state.biometricUnlock &&
!this.state.isBiometryEnrolled &&
novault ? (
<Paragraph>{strings.vaultEnableBiometrics()}</Paragraph>
) : null}
{this.state.isBiometryAvailable &&
!this.state.fingerprintAccess &&
!this.state.clearVault &&
!this.state.deleteVault &&
((!this.state.biometricUnlock && !changePassword) || !novault) ? (
<Button
onPress={() => {
this.setState({
biometricUnlock: !this.state.biometricUnlock
});
}}
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
icon="fingerprint"
width="100%"
title={strings.unlockWithBiometrics()}
iconColor={
this.state.biometricUnlock
? colors.selected.accent
: colors.primary.icon
}
type={this.state.biometricUnlock ? "transparent" : "plain"}
/>
) : null}
</View>
<DialogButtons
onPressNegative={this.close}
onPressPositive={this.onPress}
loading={loading}
positiveType={
deleteVault || clearVault ? "errorShade" : "transparent"
}
positiveTitle={
deleteVault
? strings.delete()
: clearVault
? strings.clear()
: fingerprintAccess
? strings.enable()
: this.state.revokeFingerprintAccess
? strings.revoke()
: changePassword
? strings.change()
: this.state.noteLocked
? deleteNote
? strings.delete()
: share
? strings.share()
: goToEditor
? strings.open()
: strings.unlock()
: !note.id
? strings.create()
: strings.lock()
}
/>
</View>
<Toast context="local" />
</BaseDialog>
);
}
}

View File

@@ -1,963 +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 Clipboard from "@react-native-clipboard/clipboard";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { InteractionManager, View, TextInput } from "react-native";
import Share from "react-native-share";
import { notesnook } from "../../../../e2e/test.ids";
import { db } from "../../../common/database";
import BiometricService from "../../../services/biometrics";
import { DDS } from "../../../services/device-detection";
import {
ToastManager,
Vault,
VaultRequestType,
eSendEvent,
eSubscribeEvent,
eUnSubscribeEvent
} from "../../../services/event-manager";
import Navigation from "../../../services/navigation";
import { getElevationStyle } from "../../../utils/elevation";
import {
eCloseActionSheet,
eCloseVaultDialog,
eOnLoadNote,
eOpenVaultDialog,
eUpdateNoteInEditor
} from "../../../utils/events";
import { deleteItems } from "../../../utils/functions";
import { fluidTabsRef } from "../../../utils/global-refs";
import { convertNoteToText } from "../../../utils/note-to-text";
import { sleep } from "../../../utils/time";
import BaseDialog from "../../dialog/base-dialog";
import DialogButtons from "../../dialog/dialog-buttons";
import DialogHeader from "../../dialog/dialog-header";
import { Toast } from "../../toast";
import { Button } from "../../ui/button";
import Input from "../../ui/input";
import Seperator from "../../ui/seperator";
import Paragraph from "../../ui/typography/paragraph";
import { strings } from "@notesnook/intl";
import { DefaultAppStyles } from "../../../utils/styles";
import {
isEncryptedContent,
Note,
NoteContent,
VAULT_ERRORS
} from "@notesnook/core";
import { useThemeColors } from "@notesnook/theme";
export const VaultDialog: React.FC = () => {
const { colors } = useThemeColors();
// UI State
const [visible, setVisible] = useState(false);
const [loading, setLoading] = useState(false);
const [wrongPassword, setWrongPassword] = useState(false);
const [passwordsDontMatch, setPasswordsDontMatch] = useState(false);
const [deleteAll, setDeleteAll] = useState(false);
const [biometricUnlock, setBiometricUnlock] = useState(false);
const [isBiometryAvailable, setIsBiometryAvailable] = useState(false);
const [isBiometryEnrolled, setIsBiometryEnrolled] = useState(false);
// Refs for non-UI state
const requestTypeRef = useRef<VaultRequestType | null>(null);
const noteRef = useRef<Note | undefined>(undefined);
const titleRef = useRef<string>(strings.goToEditor());
const descriptionRef = useRef<string | null>(null);
const paragraphRef = useRef<string | null>(null);
const buttonTitleRef = useRef<string | null>(null);
const positiveButtonTypeRef = useRef<"errorShade" | "transparent" | "accent">(
"transparent"
);
const customActionTitleRef = useRef<string | null>(null);
const customActionParagraphRef = useRef<string | null>(null);
const noteLockedRef = useRef(false);
const onUnlockRef = useRef<
| ((
item: Note & {
content?: NoteContent<false>;
},
password: string
) => void)
| undefined
>(undefined);
// Input refs
const passInputRef = useRef<TextInput>(null);
const confirmPassRef = useRef<TextInput>(null);
const changePassInputRef = useRef<TextInput>(null);
// Password refs
const passwordRef = useRef<string | null>(null);
const confirmPasswordRef = useRef<string | null>(null);
const newPasswordRef = useRef<string | null>(null);
const close = useCallback(() => {
if (loading) {
ToastManager.show({
heading: titleRef.current,
message: strings.pleaseWait() + "...",
type: "success",
context: "local"
});
return;
}
Navigation.queueRoutesForUpdate();
// Reset password refs
passwordRef.current = null;
confirmPasswordRef.current = null;
newPasswordRef.current = null;
// Reset refs
requestTypeRef.current = null;
noteRef.current = undefined;
titleRef.current = strings.goToEditor();
descriptionRef.current = null;
paragraphRef.current = null;
buttonTitleRef.current = null;
positiveButtonTypeRef.current = "transparent";
customActionTitleRef.current = null;
customActionParagraphRef.current = null;
noteLockedRef.current = false;
onUnlockRef.current = undefined;
// Reset UI state
setVisible(false);
setLoading(false);
setWrongPassword(false);
setPasswordsDontMatch(false);
setDeleteAll(false);
setBiometricUnlock(false);
setIsBiometryAvailable(false);
setIsBiometryEnrolled(false);
}, [loading]);
const deleteVault = useCallback(async () => {
setLoading(true);
try {
let verified = true;
if (await db.user.getUser()) {
verified = await db.user.verifyPassword(passwordRef.current || "");
}
if (verified) {
let noteIds: string[] = [];
if (deleteAll) {
const vault = await db.vaults.default();
const relations = await db.relations
.from(
{
type: "vault",
id: vault!.id
},
"note"
)
.get();
noteIds = relations.map((item) => item.toId);
}
await db.vault.delete(deleteAll);
if (deleteAll) {
noteIds.forEach((id) => {
eSendEvent(
eUpdateNoteInEditor,
{
id: id,
deleted: true
},
true
);
});
}
eSendEvent("vaultUpdated");
setLoading(false);
setTimeout(() => {
close();
}, 100);
} else {
setLoading(false);
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
}
} catch (e) {
console.error(e);
}
}, [deleteAll, close]);
const clearVault = useCallback(async () => {
setLoading(true);
try {
const vault = await db.vaults.default();
const relations = await db.relations.from(vault!, "note").get();
const noteIds = relations.map((item) => item.toId);
await db.vault.clear(passwordRef.current || "");
noteIds.forEach((id) => {
eSendEvent(
eUpdateNoteInEditor,
{
id: id,
deleted: true
},
true
);
});
setLoading(false);
close();
eSendEvent("vaultUpdated");
ToastManager.show({
message: strings.vaultCleared(),
type: "success"
});
} catch (e) {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
}
setLoading(false);
}, [close]);
const enrollFingerprint = useCallback(
async (password: string) => {
setLoading(true);
try {
await db.vault.unlock(password);
await BiometricService.storeCredentials(password);
setLoading(false);
eSendEvent("vaultUpdated");
ToastManager.show({
heading: strings.biometricUnlockEnabled(),
type: "success",
context: "global"
});
close();
} catch (e) {
close();
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
setLoading(false);
}
},
[close]
);
const takeErrorAction = useCallback(() => {
setWrongPassword(true);
setVisible(true);
setTimeout(() => {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
}, 500);
}, []);
const lockNote = useCallback(async () => {
if (!passwordRef.current || passwordRef.current.trim() === "") {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
return;
} else {
await db.vault.add(noteRef.current!.id);
eSendEvent(eUpdateNoteInEditor, noteRef.current, true);
close();
ToastManager.show({
message: strings.noteLocked(),
type: "error",
context: "local"
});
setLoading(false);
}
}, [close]);
const permanantUnlock = useCallback(() => {
db.vault
.remove(noteRef.current!.id, passwordRef.current || "")
.then(async () => {
ToastManager.show({
heading: strings.noteUnlocked(),
type: "success",
context: "global"
});
eSendEvent(eUpdateNoteInEditor, noteRef.current, true);
if (biometricUnlock && !isBiometryEnrolled) {
await enrollFingerprint(passwordRef.current || "");
}
close();
})
.catch((e) => {
takeErrorAction();
});
}, [
biometricUnlock,
isBiometryEnrolled,
close,
enrollFingerprint,
takeErrorAction
]);
const openInEditor = useCallback(
(note: Note & { content?: NoteContent<false> }) => {
close();
InteractionManager.runAfterInteractions(async () => {
eSendEvent(eOnLoadNote, {
item: note
});
if (!DDS.isTab) {
fluidTabsRef.current?.goToPage("editor");
}
});
},
[close]
);
const copyNote = useCallback(
async (note: Note & { content?: NoteContent<false> }) => {
Clipboard.setString((await convertNoteToText(note, true)) || "");
ToastManager.show({
heading: strings.noteCopied(),
type: "success",
context: "global"
});
close();
},
[close]
);
const shareNote = useCallback(
async (note: Note & { content?: NoteContent<false> }) => {
close();
try {
await Share.open({
title: note.title,
failOnCancel: false,
message: (await convertNoteToText(note)) || ""
});
} catch (e) {
console.error(e);
}
},
[close]
);
const deleteNote = useCallback(async () => {
try {
await db.vault.remove(noteRef.current!.id, passwordRef.current || "");
await deleteItems("note", [noteRef.current!.id]);
close();
} catch (e) {
takeErrorAction();
}
}, [close, takeErrorAction]);
const openNote = useCallback(async () => {
try {
if (!passwordRef.current) throw new Error("Invalid password");
const note = await db.vault.open(
noteRef.current!.id,
passwordRef.current
);
if (!note) throw new Error("Failed to unlock note.");
if (biometricUnlock && !isBiometryEnrolled) {
await enrollFingerprint(passwordRef.current || "");
}
const requestType = requestTypeRef.current;
if (requestType === VaultRequestType.GoToEditor) {
openInEditor(note);
} else if (requestType === VaultRequestType.ShareNote) {
await shareNote(note);
} else if (requestType === VaultRequestType.DeleteNote) {
await deleteNote();
} else if (requestType === VaultRequestType.CopyNote) {
await copyNote(note);
} else if (
requestType === VaultRequestType.CustomAction &&
onUnlockRef.current
) {
const password = passwordRef.current;
const unlock = onUnlockRef.current;
close();
await sleep(500);
unlock(note, password);
}
} catch (e) {
takeErrorAction();
}
}, [
biometricUnlock,
isBiometryEnrolled,
enrollFingerprint,
openInEditor,
shareNote,
deleteNote,
copyNote,
close,
takeErrorAction
]);
const unlockNote = useCallback(async () => {
if (!passwordRef.current || passwordRef.current.trim() === "") {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
return;
}
if (requestTypeRef.current === VaultRequestType.PermanentUnlock) {
permanantUnlock();
} else {
await openNote();
}
}, [permanantUnlock, openNote]);
const createVault = useCallback(async () => {
await db.vault.create(passwordRef.current || "");
if (biometricUnlock) {
await enrollFingerprint(passwordRef.current || "");
}
if (noteRef.current?.id) {
await db.vault.add(noteRef.current.id);
eSendEvent(eUpdateNoteInEditor, noteRef.current, true);
setLoading(false);
ToastManager.show({
heading: strings.noteLocked(),
type: "success",
context: "global"
});
close();
} else {
ToastManager.show({
heading: strings.vaultCreated(),
type: "success",
context: "global"
});
close();
}
eSendEvent("vaultUpdated");
}, [biometricUnlock, enrollFingerprint, close]);
const revokeFingerprintAccess = useCallback(async () => {
try {
await BiometricService.resetCredentials();
eSendEvent("vaultUpdated");
ToastManager.show({
heading: strings.biometricUnlockDisabled(),
type: "success",
context: "global"
});
} catch (e: any) {
ToastManager.show({
heading: e.message,
type: "success",
context: "global"
});
}
}, []);
const onPress = useCallback(async () => {
const requestType = requestTypeRef.current;
if (requestType === VaultRequestType.RevokeFingerprint) {
await revokeFingerprintAccess();
close();
return;
}
if (loading) return;
if (!passwordRef.current) {
ToastManager.show({
heading: strings.passwordNotEntered(),
type: "error",
context: "local"
});
return;
}
if (requestType === VaultRequestType.CreateVault) {
if (passwordRef.current !== confirmPasswordRef.current) {
ToastManager.show({
heading: strings.passwordNotMatched(),
type: "error",
context: "local"
});
setPasswordsDontMatch(true);
return;
}
createVault();
} else if (requestType === VaultRequestType.ChangePassword) {
setLoading(true);
db.vault
.changePassword(passwordRef.current, newPasswordRef.current || "")
.then(() => {
setLoading(false);
if (biometricUnlock) {
enrollFingerprint(newPasswordRef.current || "");
}
ToastManager.show({
heading: strings.passwordUpdated(),
type: "success",
context: "global"
});
close();
})
.catch((e) => {
setLoading(false);
if (e.message === VAULT_ERRORS.wrongPassword) {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
} else {
ToastManager.error(e);
}
});
} else if (requestType === VaultRequestType.LockNote) {
if (!passwordRef.current || passwordRef.current.trim() === "") {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
setWrongPassword(true);
return;
}
db.vault
.unlock(passwordRef.current)
.then(async (unlocked) => {
if (unlocked) {
setWrongPassword(false);
await lockNote();
} else {
takeErrorAction();
}
})
.catch((e) => {
takeErrorAction();
});
} else if (
requestType === VaultRequestType.UnlockNote ||
requestType === VaultRequestType.PermanentUnlock ||
requestType === VaultRequestType.GoToEditor ||
requestType === VaultRequestType.ShareNote ||
requestType === VaultRequestType.CopyNote ||
requestType === VaultRequestType.DeleteNote ||
requestType === VaultRequestType.CustomAction
) {
if (!passwordRef.current || passwordRef.current.trim() === "") {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
setWrongPassword(true);
return;
}
if (noteLockedRef.current) {
await unlockNote();
} else {
console.log("Error: Note should be locked for this operation");
}
} else if (requestType === VaultRequestType.EnableFingerprint) {
enrollFingerprint(passwordRef.current);
} else if (requestType === VaultRequestType.ClearVault) {
await clearVault();
} else if (requestType === VaultRequestType.DeleteVault) {
await deleteVault();
}
}, [
loading,
biometricUnlock,
revokeFingerprintAccess,
close,
createVault,
enrollFingerprint,
unlockNote,
lockNote,
takeErrorAction,
clearVault,
deleteVault
]);
const onPressFingerprintAuth = useCallback(
async (title?: string, description?: string) => {
try {
const credentials = await BiometricService.getCredentials(
title || titleRef.current,
description || descriptionRef.current || ""
);
if (!credentials) throw new Error("Failed to get user credentials");
if (credentials?.password) {
passwordRef.current = credentials.password;
onPress();
} else {
eSendEvent(eCloseActionSheet);
await sleep(300);
setVisible(true);
}
} catch (e) {
console.error(e);
}
},
[onPress]
);
const open = useCallback(
async (data: Vault) => {
const biometry = await BiometricService.isBiometryAvailable();
const available = !!biometry;
const fingerprint = await BiometricService.hasInternetCredentials();
if (data.item) {
const locked = await db.vaults.itemExists(data.item);
noteLockedRef.current = locked;
if (!locked) {
const content = await db.content.findByNoteId(data.item!.id);
if (content && isEncryptedContent(content)) {
noteLockedRef.current = true;
}
}
}
// Set refs
noteRef.current = data.item;
titleRef.current = data.title || strings.goToEditor();
descriptionRef.current = data.description || null;
paragraphRef.current = data.paragraph || null;
buttonTitleRef.current = data.buttonTitle || null;
positiveButtonTypeRef.current = data.positiveButtonType || "transparent";
customActionTitleRef.current = data.customActionTitle || null;
customActionParagraphRef.current = data.customActionParagraph || null;
onUnlockRef.current = data.onUnlock;
requestTypeRef.current = data.requestType;
// Set UI state
setIsBiometryAvailable(available);
setIsBiometryEnrolled(fingerprint);
setBiometricUnlock(fingerprint);
setWrongPassword(false);
setPasswordsDontMatch(false);
setDeleteAll(false);
setLoading(false);
// Auto-unlock with fingerprint if applicable
const canAutoUnlock =
fingerprint &&
data.requestType !== VaultRequestType.EnableFingerprint &&
data.requestType !== VaultRequestType.RevokeFingerprint &&
data.requestType !== VaultRequestType.ChangePassword &&
data.requestType !== VaultRequestType.ClearVault &&
data.requestType !== VaultRequestType.DeleteVault &&
data.requestType !== VaultRequestType.CustomAction &&
data.requestType !== VaultRequestType.PermanentUnlock;
if (canAutoUnlock) {
await onPressFingerprintAuth(data.title, data.description);
} else {
setVisible(true);
}
},
[onPressFingerprintAuth]
);
useEffect(() => {
eSubscribeEvent(eOpenVaultDialog, open);
eSubscribeEvent(eCloseVaultDialog, close);
return () => {
eUnSubscribeEvent(eOpenVaultDialog, open);
eUnSubscribeEvent(eCloseVaultDialog, close);
};
}, [open, close]);
if (!visible) return null;
const requestType = requestTypeRef.current;
const isCreateVault = requestType === VaultRequestType.CreateVault;
const isChangePassword = requestType === VaultRequestType.ChangePassword;
const isClearVault = requestType === VaultRequestType.ClearVault;
const isDeleteVault = requestType === VaultRequestType.DeleteVault;
const isRevokeFingerprint =
requestType === VaultRequestType.RevokeFingerprint;
const isEnableFingerprint =
requestType === VaultRequestType.EnableFingerprint;
const isCustomAction = requestType === VaultRequestType.CustomAction;
const isDeleteNote = requestType === VaultRequestType.DeleteNote;
const isShareNote = requestType === VaultRequestType.ShareNote;
const isGoToEditor = requestType === VaultRequestType.GoToEditor;
return (
<BaseDialog
onShow={async () => {
await sleep(100);
passInputRef.current?.focus();
}}
statusBarTranslucent={false}
onRequestClose={close}
visible={true}
>
<View
style={{
...getElevationStyle(5),
width: DDS.isTab ? 350 : "85%",
borderRadius: 10,
backgroundColor: colors.primary.background,
paddingTop: 12,
overflow: "hidden"
}}
>
<DialogHeader
title={titleRef.current}
paragraph={
paragraphRef.current || customActionParagraphRef.current || ""
}
icon="shield"
padding={12}
/>
<Seperator half />
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP
}}
>
{(isChangePassword ||
isClearVault ||
!isCreateVault ||
isDeleteVault ||
isCustomAction) &&
!isRevokeFingerprint ? (
<>
<Input
fwdRef={passInputRef}
editable={!loading}
autoCapitalize="none"
testID={notesnook.ids.dialogs.vault.pwd}
onChangeText={(value) => {
passwordRef.current = value;
}}
marginBottom={
!biometricUnlock ||
!isBiometryEnrolled ||
isCreateVault ||
isChangePassword ||
isCustomAction
? 0
: 10
}
onSubmit={() => {
if (isChangePassword) {
confirmPassRef.current?.focus();
} else {
onPress();
}
}}
autoComplete="password"
returnKeyLabel={
isChangePassword ? strings.next() : titleRef.current
}
returnKeyType={isChangePassword ? "next" : "done"}
secureTextEntry
placeholder={
isChangePassword
? strings.currentPassword()
: strings.password()
}
/>
{!biometricUnlock ||
!isBiometryEnrolled ||
!isBiometryAvailable ||
isCreateVault ||
isChangePassword ||
isCustomAction ||
isDeleteVault ? null : (
<Button
onPress={() =>
onPressFingerprintAuth(strings.unlockNote(), "")
}
icon="fingerprint"
width="100%"
title={strings.unlockWithBiometrics()}
type="transparent"
/>
)}
</>
) : null}
{isDeleteVault && (
<Button
onPress={() => setDeleteAll(!deleteAll)}
icon={
deleteAll
? "check-circle-outline"
: "checkbox-blank-circle-outline"
}
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
width="100%"
title={strings.deleteAllNotes()}
type="errorShade"
/>
)}
{isChangePassword ? (
<>
<Seperator half />
<Input
fwdRef={confirmPassRef}
editable={!loading}
testID={notesnook.ids.dialogs.vault.changePwd}
autoCapitalize="none"
onChangeText={(value) => {
newPasswordRef.current = value;
}}
autoComplete="password"
onSubmit={() => {
onPress();
}}
returnKeyLabel="Change"
returnKeyType="done"
secureTextEntry
placeholder={strings.newPassword()}
/>
</>
) : null}
{isCreateVault ? (
<View>
<Input
fwdRef={passInputRef}
autoCapitalize="none"
testID={notesnook.ids.dialogs.vault.pwd}
onChangeText={(value) => {
passwordRef.current = value;
}}
autoComplete="password"
returnKeyLabel={strings.next()}
returnKeyType="next"
secureTextEntry
onSubmit={() => {
confirmPassRef.current?.focus();
}}
placeholder={strings.password()}
/>
<Input
fwdRef={confirmPassRef}
autoCapitalize="none"
testID={notesnook.ids.dialogs.vault.pwdAlt}
secureTextEntry
validationType="confirmPassword"
customValidator={() => passwordRef.current || ""}
errorMessage="Passwords do not match."
onErrorCheck={() => null}
marginBottom={0}
autoComplete="password"
returnKeyLabel="Create"
returnKeyType="done"
onChangeText={(value) => {
confirmPasswordRef.current = value;
if (value !== passwordRef.current) {
setPasswordsDontMatch(true);
} else {
setPasswordsDontMatch(false);
}
}}
onSubmit={() => {
onPress();
}}
placeholder={strings.confirmPassword()}
/>
</View>
) : null}
{biometricUnlock && !isBiometryEnrolled && !isCreateVault ? (
<Paragraph>{strings.vaultEnableBiometrics()}</Paragraph>
) : null}
{!biometricUnlock &&
!isBiometryEnrolled &&
isBiometryAvailable &&
(requestType === VaultRequestType.CopyNote ||
requestType === VaultRequestType.DeleteNote ||
requestType === VaultRequestType.ShareNote ||
requestType === VaultRequestType.CustomAction ||
requestType === VaultRequestType.GoToEditor ||
requestType === VaultRequestType.PermanentUnlock ||
requestType === VaultRequestType.LockNote) ? (
<Button
onPress={() => {
setBiometricUnlock(!biometricUnlock);
}}
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
icon="fingerprint"
width="100%"
title={strings.unlockWithBiometrics()}
iconColor={
biometricUnlock ? colors.selected.accent : colors.primary.icon
}
type={biometricUnlock ? "transparent" : "plain"}
/>
) : null}
</View>
<DialogButtons
onPressNegative={close}
onPressPositive={onPress}
loading={loading}
positiveType={positiveButtonTypeRef.current}
positiveTitle={buttonTitleRef.current || strings.unlock()}
/>
</View>
<Toast context="local" />
</BaseDialog>
);
};

View File

@@ -22,6 +22,7 @@ import React, {
RefObject,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState
} from "react";
@@ -30,7 +31,6 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
runOnJS,
SharedValue,
SnappySpringConfig,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
@@ -38,6 +38,8 @@ import Animated, {
WithSpringConfig,
withTiming
} from "react-native-reanimated";
import { useTabStore } from "../../screens/editor/tiptap/use-tab-store";
import { getAppState } from "../../screens/editor/tiptap/utils";
import { eSendEvent } from "../../services/event-manager";
import { useSettingStore } from "../../stores/use-setting-store";
import { eClearEditor } from "../../utils/events";
@@ -65,7 +67,7 @@ export interface TabsRef {
page: () => FluidTabPage;
setScrollEnabled: () => true;
isDrawerOpen: () => boolean;
node: RefObject<Animated.View | null>;
node: RefObject<Animated.View>;
tabChangedFromSwipeAction: SharedValue<boolean>;
}
@@ -81,9 +83,18 @@ export const FluidPanels = forwardRef<TabsRef, TabProps>(function FluidTabs(
}: TabProps,
ref
) {
const appState = useMemo(() => getAppState(), []);
const deviceMode = useSettingStore((state) => state.deviceMode);
const fullscreen = useSettingStore((state) => state.fullscreen);
const translateX = useSharedValue(widths ? widths.sidebar : 0);
const translateX = useSharedValue(
widths
? appState &&
appState?.movedAway === false &&
useTabStore.getState().getCurrentNoteId()
? widths.sidebar + widths.list
: widths.sidebar
: 0
);
const startX = useSharedValue(0);
const currentTab = useSharedValue(1);
const previousTab = useSharedValue(1);
@@ -99,6 +110,7 @@ export const FluidPanels = forwardRef<TabsRef, TabProps>(function FluidTabs(
const containerWidth = widths
? widths.sidebar + widths.list + widths.editor
: dimensions.width;
const drawerPosition = 0;
const homePosition = widths.sidebar;
const editorPosition = widths.sidebar + widths.list;
@@ -113,7 +125,10 @@ export const FluidPanels = forwardRef<TabsRef, TabProps>(function FluidTabs(
translateX.value = 0;
} else {
if (prevWidths.current?.sidebar !== widths.sidebar) {
translateX.value = widths.sidebar;
translateX.value =
appState && appState?.movedAway === false
? editorPosition
: widths.sidebar;
if (translateX.value === editorPosition) {
onChangeTab?.({ i: 2, from: 1 });
}
@@ -121,7 +136,15 @@ export const FluidPanels = forwardRef<TabsRef, TabProps>(function FluidTabs(
}
isLoaded.current = true;
prevWidths.current = widths;
}, [deviceMode, widths, fullscreen, translateX, editorPosition, onChangeTab]);
}, [
deviceMode,
widths,
fullscreen,
translateX,
editorPosition,
appState,
onChangeTab
]);
useEffect(() => {
const sub = BackHandler.addEventListener("hardwareBackPress", () => {
@@ -331,10 +354,13 @@ export const FluidPanels = forwardRef<TabsRef, TabProps>(function FluidTabs(
const finalValue = isSwipeLeft
? translateX.value - velocityX / 40.0
: translateX.value + velocityX / 40.0;
const velocity = velocityX / 5000;
const animationConfig: WithSpringConfig = {
velocity: velocity,
...SnappySpringConfig
velocity: velocityX,
mass: 0.5,
overshootClamping: true,
damping: 800,
stiffness: 800
};
if (finalValue < homePosition) {
@@ -417,7 +443,15 @@ export const FluidPanels = forwardRef<TabsRef, TabProps>(function FluidTabs(
width: containerWidth,
flexDirection: "row"
},
animatedStyles
deviceMode === "tablet"
? {
transform: [
{
translateX: 0
}
]
}
: animatedStyles
]}
>
{children}

View File

@@ -50,7 +50,7 @@ export const Header = ({
onLeftMenuButtonPress?: () => void;
renderedInRoute?: RouteName;
id?: string;
title?: string;
title: string;
canGoBack?: boolean;
onPressDefaultRightButton?: () => void;
hasSearch?: boolean;
@@ -114,16 +114,7 @@ export const Header = ({
onLeftButtonPress={onLeftMenuButtonPress}
/>
{!title ? (
<View
style={{
width: 100,
backgroundColor: colors.primary.hover,
height: 10,
borderRadius: 100
}}
/>
) : hasSearch ? (
{hasSearch ? (
<Paragraph>
{selectionMode
? `${selectedItemsList.length} selected`

View File

@@ -46,7 +46,7 @@ const ImagePreview = () => {
const [visible, setVisible] = useState(false);
const [image, setImage] = useState<string>();
const [loading, setLoading] = useState(false);
const imageRef = useRef<ImageAttributes>(null);
const imageRef = useRef<ImageAttributes>();
const insets = useGlobalSafeAreaInsets();
const [showHeader, setShowHeader] = useState(true);

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