Compare commits

..

78 Commits

Author SHA1 Message Date
01zulfi
8d5f6dd909 web: retain email/password field data when navigating back from mfa screens
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-07-22 14:18:09 +05:00
01zulfi
ea2af9fd98 theme: set button's line height to 100% for new variants
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-07-22 14:12:41 +05:00
01zulfi
445701c7d5 web: enhance MFA code component styling and layout
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-07-22 11:02:56 +05:00
01zulfi
333a4bddd7 web: define & use new button variants
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-07-21 15:21:30 +05:00
01zulfi
acafed779c web: update button text in auth container
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-07-21 14:13:06 +05:00
Abdullah Atta
3cd2d3dfef web: minor ui fixes 2026-07-21 13:44:06 +05:00
01zulfi
34f4b96473 web: auth pages design fixes
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-07-21 13:24:39 +05:00
01zulfi
037d0a6f2e web: redesign auth pages
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-07-15 16:43:28 +05:00
Ammar Ahmed
701ac02ff1 mobile: change date-picker library in the app 2026-07-13 12:23:32 +05:00
Ammar Ahmed
c60fb9e7fd mobile: add reminder screen 2026-07-11 11:22:57 +05:00
Ammar Ahmed
657c18ffed mobile: fix list headers 2026-07-09 13:53:14 +05:00
Ammar Ahmed
573c218fc8 mobile: minor fixes in settings screen 2026-07-09 13:46:10 +05:00
01zulfi
b38e20b2ea ui: rewrap @theme-ui's remaining component (#10073)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-07-09 12:53:36 +05:00
01zulfi
fd274c4da4 ui: rewrap @theme-ui's flex, box, slider && add theme colors strict typecheck (#10067)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-07-09 11:20:59 +05:00
01zulfi
d1f7169e1c ui: rewrap @theme-ui's button component (#10063)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-07-09 11:20:53 +05:00
01zulfi
2f972bf5d0 theme: add fontsize, spacing, radius variables (#10062)
* theme: add fontsize, spacing, radius variables
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* Apply suggestion from @thecodrr

Signed-off-by: Abdullah Atta <thecodrr@protonmail.com>

* Apply suggestion from @thecodrr

Signed-off-by: Abdullah Atta <thecodrr@protonmail.com>

---------

Signed-off-by: Abdullah Atta <thecodrr@protonmail.com>
Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2026-07-09 11:20:48 +05:00
Ammar Ahmed
97d530cfe3 mobile: fix trash and note item ui 2026-07-09 10:36:20 +05:00
Ammar Ahmed
64c87a4687 mobile: update ui 2026-07-08 11:29:01 +05:00
Ammar Ahmed
7132ed60e1 mobile: update ui 2026-07-08 09:57:25 +05:00
Ammar Ahmed
3dcc107ae4 mobile: match recovery code screen with design 2026-07-03 12:43:12 +05:00
Ammar Ahmed
4faa71419d mobile: fix text cut off on setting up your account screen 2026-07-03 12:35:35 +05:00
Ammar Ahmed
7f7f006f15 mobile: update ui 2026-07-01 20:15:26 +05:00
Ammar Ahmed
317f889569 mobile: update ui 2026-07-01 13:45:18 +05:00
Ammar Ahmed
0f0d239e11 mobile: fix sort sheet ui 2026-07-01 11:11:56 +05:00
Ammar Ahmed
639d51d2e0 mobile: fix note item ui 2026-07-01 10:43:19 +05:00
Ammar Ahmed
edb6f16fd6 mobile: update ui to match design 2026-06-29 22:37:29 +05:00
Ammar Ahmed
cca26b117e mobile: cleanup 2026-06-29 11:27:44 +05:00
Ammar Ahmed
d8e096f15c mobile: push ui updates 2026-06-29 11:27:03 +05:00
kashaf-ansari-dev
f3d798dc1c mobile: fix color spacing notebook filter input
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-25 13:21:43 +05:00
kashaf-ansari-dev
e54a91a2d7 mobile: fix notebook filter input keyboard hiding
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-25 13:21:24 +05:00
kashaf-ansari-dev
a4b6e9e476 mobile: ui fixes side menu items and notebook items
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-25 13:20:55 +05:00
Ammar Ahmed
c6579a0a88 mobile: minor fixes 2026-06-25 11:56:01 +05:00
Ammar Ahmed
90ba685aa6 mobile: fix minor ui issues 2026-06-25 11:46:56 +05:00
Ammar Ahmed
e2b398efdb common: fix incorrect title for createNoteFromNotificationDrawer feature 2026-06-25 11:46:36 +05:00
Ammar Ahmed
f29d9b9948 mobile: fix 2fa method selection ui 2026-06-22 11:17:59 +05:00
Ammar Ahmed
8daea6ac27 mobile: fix missing command error 2026-06-19 15:56:32 +05:00
Ammar Ahmed
967b3ce451 mobile: update lockfiles 2026-06-19 15:30:42 +05:00
Ammar Ahmed
f1a05711ba mobile: update mobile app settings ui (#9994)
* mobile: update onboarding screen ui

* mobile: update ui for paywall flow

* mobile: update ui

* mobile: update ui

* mobile: fix text capitalization

* mobile: fix Login screen text capitalization and eye icon mismatch with design

* mobile: fix two-factor ui

* mobile: ui fixes

* mobile: update basic settings ui and reorganize settings screen

* mobile: update change-password & recovery-key sheets

* mobile: update 2fa sheets design

* mobile: add force sync sheets

* mobile: replace hard coded strings

* mobile: update theme-selector and notesnook circle ui

* mobile: update behavior settings

* mobile: complete settings module

* mobile: fix errors

* mobile: settings ui update

* mobile: add app-lock password change screens

* mobile: add inbox api

* mobile: finalize settings ui
2026-06-19 13:55:34 +05:00
Ammar Ahmed
1e73a511c1 mobile: fix password placeholders 2026-06-16 13:43:17 +05:00
Ammar Ahmed
8fee146a32 mobile: undo hardcoded settings.introCompleted. 2026-06-16 13:43:17 +05:00
Ammar Ahmed
ff3570bae8 mobile: fix button color 2026-06-16 13:43:17 +05:00
Ammar Ahmed
7ffaab036b mobile: ui fixes 2026-06-16 13:43:16 +05:00
Ammar Ahmed
73d2fe1271 mobile: fix setting up account svg colors in light mode 2026-06-16 13:42:09 +05:00
Ammar Ahmed
b6ace863b3 mobile: minor adjustments 2026-06-16 13:42:09 +05:00
Ammar Ahmed
912fa3fe49 mobile: make intro illustration work with light mode 2026-06-16 13:42:09 +05:00
Ammar Ahmed
394c12856b mobile: minor ui fixes 2026-06-16 13:42:09 +05:00
Ammar Ahmed
1473a70681 mobile: support dark/light mode on paywall logos 2026-06-16 13:42:09 +05:00
Ammar Ahmed
6e4d5490bc mobile: show setup account screen after signup 2026-06-16 13:42:09 +05:00
Ammar Ahmed
1a864743d0 mobile: fix pin input recovery code 2026-06-16 13:42:09 +05:00
Ammar Ahmed
e87a9297ce mobile: fix two-factor ui 2026-06-16 13:42:07 +05:00
Ammar Ahmed
72e2010074 mobile: fix button and text alignment in compare plans screen 2026-06-16 13:14:44 +05:00
Ammar Ahmed
5d151045cd mobile: fix login allowed without entering password field 2026-06-16 13:14:44 +05:00
Ammar Ahmed
60d7208696 mobile: fix Login screen text capitalization and eye icon mismatch with design 2026-06-16 13:14:43 +05:00
Ammar Ahmed
882a667269 mobile: fix Resend code button not working on 2FA screen 2026-06-16 13:14:19 +05:00
Ammar Ahmed
7583e16152 mobile: update intl strings 2026-06-16 13:14:19 +05:00
Ammar Ahmed
02f537e296 common: match feature titles with design 2026-06-16 13:14:19 +05:00
Ammar Ahmed
a462e5a10e mobile: fix Extra punctuation in “Setting up your account” screen text 2026-06-16 13:14:19 +05:00
Ammar Ahmed
800c1ccd4d mobile: hide progress pills in setting up your account if flow is not introductory 2026-06-16 13:14:19 +05:00
Ammar Ahmed
33880c682a mobile: fix wording if user already has an account 2026-06-16 13:14:19 +05:00
Ammar Ahmed
a2cd14ffc8 mobile: fix signup input secure entry icon 2026-06-16 13:14:19 +05:00
Ammar Ahmed
7ba5e72f64 mobile: fix text capitalization 2026-06-16 13:14:17 +05:00
Ammar Ahmed
0e5b216773 mobile: fix colors in signup 2026-06-16 13:13:55 +05:00
Ammar Ahmed
ee43a020b2 mobile: fix signup footer text and position 2026-06-16 13:13:54 +05:00
Ammar Ahmed
364225b34b mobile: fix illustration colors 2026-06-16 13:13:54 +05:00
Ammar Ahmed
cb862e7293 mobile: fix errors 2026-06-16 13:13:52 +05:00
Ammar Ahmed
247a2824e8 mobile: update ui 2026-06-16 13:12:20 +05:00
Ammar Ahmed
51dceb9715 mobile: update form input styles 2026-06-16 13:12:18 +05:00
Ammar Ahmed
a0ba1fb1d2 mobile: updated colors 2026-06-16 13:09:49 +05:00
Ammar Ahmed
7ae1cc85ac theme: add new variant & colors 2026-06-16 13:09:49 +05:00
Ammar Ahmed
67d25797d6 mobile: update svg logos in paywall 2026-06-16 13:09:49 +05:00
Ammar Ahmed
b3b00fc7cf mobile: enable repack commands 2026-06-16 13:09:49 +05:00
Ammar Ahmed
ba253db226 mobile: fix ts errors 2026-06-16 13:09:49 +05:00
Ammar Ahmed
9db7795e31 mobile: cleanup 2026-06-16 13:09:49 +05:00
Ammar Ahmed
526487c37e mobile: update ui 2026-06-16 13:09:48 +05:00
Ammar Ahmed
37e7d44587 mobile: update ui for paywall flow 2026-06-16 13:04:51 +05:00
Ammar Ahmed
2e09a691e7 mobile: update ui for auth screens 2026-06-16 13:03:46 +05:00
Ammar Ahmed
1c8e43d085 mobile: update onboarding screen ui 2026-06-16 13:03:44 +05:00
Ammar Ahmed
6ee2e777c2 mobile: add style guide baseline 2026-06-16 13:02:58 +05:00
1000 changed files with 45582 additions and 47092 deletions

View File

@@ -10,7 +10,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Enable KVM
run: |

View File

@@ -1,104 +0,0 @@
name: Notesnook Android Preview Build
# UNTRUSTED stage. Runs on `pull_request`, so fork code is checked out and
# compiled with a read-only GITHUB_TOKEN and NO repository secrets. The release
# APK is built without any signing secret (the same as before). Firebase
# distribution and the PR comment happen in android.preview.publish.yml, which
# runs in the trusted `workflow_run` context and never executes fork code.
# Because no secrets are exposed here, the build runs automatically for every
# PR (including forks) with no authorization gate.
on:
pull_request:
types: [opened, reopened, synchronize]
branches: [master, beta]
paths:
- "apps/mobile/**"
- "packages/**"
- ".github/workflows/android.preview.build.yml"
- ".github/workflows/android.preview.publish.yml"
concurrency:
group: android-preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-22.04
env:
STAGING_BUILD: true
steps:
- name: Checkout PR code
uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- 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:
tool-cache: false
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: Stage build artifact
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/artifact"
cp apps/mobile/android/app/build/outputs/apk/release/app-arm64-v8a-release.apk \
"$RUNNER_TEMP/artifact/app-preview.apk"
{
echo "PR_NUMBER=${{ github.event.pull_request.number }}"
echo "HEAD_SHA=${{ github.event.pull_request.head.sha }}"
} > "$RUNNER_TEMP/artifact/pr-meta.env"
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: android-preview-build
path: ${{ runner.temp }}/artifact
if-no-files-found: error
retention-days: 1
- name: Upload sourcemaps
uses: actions/upload-artifact@v4
with:
name: sourcemaps
path: |
apps/mobile/android/app/build/generated/sourcemaps/**/*.map

View File

@@ -0,0 +1,135 @@
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

@@ -1,72 +0,0 @@
name: Notesnook Android Preview Publish
# TRUSTED stage. Runs via `workflow_run` after the build workflow finishes, so
# it has the base repo's secrets and a write-scoped token. It downloads the APK
# the build produced and distributes it + posts the PR comment. It never checks
# out or executes fork code.
on:
workflow_run:
workflows: ["Notesnook Android Preview Build"]
types: [completed]
jobs:
publish:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: android-preview-build
path: ${{ runner.temp }}/artifact
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Load PR metadata
run: cat "$RUNNER_TEMP/artifact/pr-meta.env" >> "$GITHUB_ENV"
- name: Publish to Firebase
id: firebase-output
uses: wzieba/Firebase-Distribution-Github-Action@v1
with:
appId: ${{ secrets.FIREBASE_APP_ID }}
serviceCredentialsFileContent: ${{ secrets.QA_SERVICE_ACCOUNT }}
groups: testers
file: ${{ runner.temp }}/artifact/app-preview.apk
releaseNotes: Preview for https://github.com/${{ github.repository }}/pull/${{ env.PR_NUMBER }}
- name: Post or update PR comment
uses: actions/github-script@v7
env:
preview_url: ${{ steps.firebase-output.outputs.TESTING_URI }}
with:
script: |
const marker = '<!-- android-preview-comment -->';
const prNumber = Number(process.env.PR_NUMBER);
if (!prNumber) return;
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.HEAD_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

@@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v2
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v2
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -35,7 +35,7 @@ jobs:
needs: authorize
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}

View File

@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -56,7 +56,7 @@ jobs:
macos-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
steps:
- name: Check out Git repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -127,7 +127,7 @@ jobs:
linux-x64-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
steps:
- name: Check out Git repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -171,7 +171,7 @@ jobs:
linux-arm64-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
steps:
- name: Check out Git repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -210,12 +210,12 @@ jobs:
build-windows:
name: Build for Windows
needs: build
runs-on: windows-2022
runs-on: windows-latest
outputs:
windows-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
steps:
- name: Check out Git repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -48,7 +48,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -107,7 +107,7 @@ jobs:
target_commitish: ${{ github.ref }}
- name: Generate flatpak sources
if: inputs.build-linux
if: inputs.publish-github && inputs.build-linux
run: |
sudo apt-get update
sudo apt-get install -y --quiet flatpak flatpak-builder pipx
@@ -127,13 +127,6 @@ jobs:
files: ./generated-sources.json
prerelease: ${{ endsWith(steps.app_metadata.outputs.app_version, '-beta') }}
- name: Upload generated-sources.json as artifact
uses: actions/upload-artifact@v4
if: inputs.build-linux
with:
name: generated-sources
path: ./generated-sources.json
build-macos:
name: Build for macOS
needs: build
@@ -142,7 +135,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -189,9 +182,7 @@ jobs:
- name: Install sqlite-better-trigram for all arch
run: |
npm i --cpu arm64 sqlite-better-trigram
npm i --cpu arm64 sqlite3-fts5-html
npm i --cpu x64 sqlite-better-trigram
npm i --cpu x64 sqlite3-fts5-html
working-directory: ./apps/desktop
- name: Install provisioning profile
@@ -246,7 +237,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -273,7 +264,6 @@ jobs:
- name: Install sqlite-better-trigram for all arch
run: |
npm i --cpu x64 sqlite-better-trigram
npm i --cpu x64 sqlite3-fts5-html
working-directory: ./apps/desktop
- name: Build Electron wrapper
@@ -314,7 +304,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -341,7 +331,6 @@ jobs:
- name: Install sqlite-better-trigram for all arch
run: |
npm i --cpu arm64 sqlite-better-trigram
npm i --cpu arm64 sqlite3-fts5-html
working-directory: ./apps/desktop
- name: Build Electron wrapper
@@ -374,11 +363,11 @@ jobs:
name: Build for Windows
needs: build
if: inputs.build-windows
runs-on: windows-2022
runs-on: windows-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup .NET Core SDK
uses: actions/setup-dotnet@v2

View File

@@ -18,13 +18,12 @@ on:
- ".github/workflows/desktop.tests.yml"
jobs:
test-macos-x64:
name: Test macOS x64
runs-on: macos-15-intel
build:
name: Build
runs-on: ubuntu-22.04
steps:
- name: Check out Git repository
uses: actions/checkout@v5
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -34,10 +33,44 @@ jobs:
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=web
- name: Install sqlite-better-trigram for all arch
- name: Generate desktop build
run: npm run tx @notesnook/web:build:desktop
- name: Archive build artifact
uses: actions/upload-artifact@v4
with:
name: build
path: apps/web/build/**/*
test-macos-x64:
name: Test macOS x64
needs: build
runs-on: macos-15-intel
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 i --cpu x64 sqlite-better-trigram
npm i --cpu x64 sqlite3-fts5-html
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=desktop
- name: Build Electron wrapper
run: npm run tx @notesnook/desktop:release
- name: Build app
run: |
yarn electron-builder --config=electron-builder.config.js --mac --dir --x64
working-directory: ./apps/desktop
- name: Run tests x64
@@ -54,24 +87,33 @@ jobs:
test-macos:
name: Test macOS
needs: build
runs-on: macos-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v5
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=web
npm run bootstrap -- --scope=desktop
- name: Install sqlite-better-trigram for all arch
- name: Build Electron wrapper
run: npm run tx @notesnook/desktop:release
- name: Build app
run: |
npm i --cpu arm64 sqlite-better-trigram
npm i --cpu arm64 sqlite3-fts5-html
yarn electron-builder --config=electron-builder.config.js --mac --dir --arm64
working-directory: ./apps/desktop
- name: Run tests arm64
@@ -88,24 +130,39 @@ jobs:
test-linux:
name: Test for Linux
needs: build
runs-on: ubuntu-22.04
steps:
- name: Check out Git repository
uses: actions/checkout@v5
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=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
npm i --cpu x64 sqlite3-fts5-html
working-directory: ./apps/desktop
- name: Build Electron wrapper
run: npm run tx @notesnook/desktop:release
- name: Build app
run: |
yarn electron-builder --config=electron-builder.config.js --linux --dir --arm64 --x64
working-directory: ./apps/desktop
- name: Run tests
@@ -122,26 +179,44 @@ jobs:
test-windows:
name: Test for Windows
runs-on: windows-2022
needs: build
runs-on: windows-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v5
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=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
npm i --cpu arm64 sqlite3-fts5-html
npm i --cpu x64 sqlite3-fts5-html
working-directory: ./apps/desktop
- name: Build Electron wrapper
run: node scripts/execute.mjs @notesnook/desktop:release
- name: Build app
run: |
npx cross-env NOTESNOOK_STAGING=true yarn electron-builder --config=electron-builder.config.js --win --dir --arm64 --x64
working-directory: ./apps/desktop
- name: Run tests
run: npm run test
working-directory: ./apps/desktop

View File

@@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -1,79 +0,0 @@
name: Notesnook Help PR Preview
on:
pull_request:
types: [opened, reopened, synchronize]
branches: [master, beta]
paths:
- "docs/help/**"
# re-run workflow if workflow file changes
- ".github/workflows/help.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@v5
with:
fetch-depth: 0
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Install dependencies
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=help
- name: Build help
run: npm run build:help
- name: Deploy to Cloudflare
id: deploy
working-directory: ./docs/help
run: |
set -euo pipefail
DEPLOY_OUT=$(npx --yes wrangler versions upload 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 = '<!-- docs-pages-preview-comment -->';
const prNumber = context.issue.number;
const previewUrl = process.env.preview_url || '';
const body = `${marker}\n**Cloudflare Pages Docs 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

@@ -2,6 +2,11 @@ name: Publish Notesnook Help
on:
workflow_dispatch:
push:
branches:
- "master"
paths:
- "docs/help/**"
jobs:
build:
@@ -10,20 +15,16 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions-rs/toolchain@v1
with:
# VitePress reads git history to show the last updated date per page.
fetch-depth: 0
toolchain: stable
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Install docgen
run: cargo install --git https://github.com/thecodrr/docgen
- name: Install dependencies
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=help
- name: Build help
run: npm run build:help
- name: Build site
run: docgen build --release
working-directory: docs/help
- name: Setup environment
run: |
@@ -31,4 +32,4 @@ jobs:
echo "CLOUDFLARE_API_TOKEN=${{ secrets.CLOUDFLARE_API_TOKEN }}" >> $GITHUB_ENV
- name: Publish on Cloudflare Pages
run: npx --yes wrangler deploy
run: npx --yes wrangler pages deploy --project-name notesnook-help ./docs/help/site/ --branch main

View File

@@ -1,152 +0,0 @@
name: Notesnook iOS Preview Build
# UNTRUSTED stage. Runs on `pull_request`, so fork code is checked out and
# compiled with a read-only GITHUB_TOKEN and NO repository secrets. It only
# produces an *unsigned* archive. Signing, Firebase distribution and PR
# comments happen in ios.preview.publish.yml, which runs in the trusted
# `workflow_run` context and never executes fork code. Because no secrets are
# exposed here, the build runs automatically for every PR (including forks)
# with no authorization gate.
on:
pull_request:
types: [opened, reopened, synchronize]
branches: [master, beta]
paths:
- "apps/mobile/**"
- "packages/**"
- ".github/workflows/ios.preview.build.yml"
- ".github/workflows/ios.preview.publish.yml"
# A fork that spams pushes shouldn't queue up macOS builds.
concurrency:
group: ios-preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
build:
runs-on: macos-26
timeout-minutes: 60
steps:
- name: Checkout PR code
uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- 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: Setup iOS Platform
run: |
# GitHub runs this as `/bin/bash -e`, so `set +e` is required -- without
# it the first failure aborts the step before any retry can happen.
# Both commands hit "Unable to connect to simulator" (exit 70) when
# CoreSimulatorService is wedged, so retry each and bounce it between.
set +e
DL_DIR="$RUNNER_TEMP/ios-platform"
retry() {
for i in 1 2 3; do
"$@" && return 0
echo "Attempt $i failed ($*); resetting CoreSimulator..."
sudo killall -9 com.apple.CoreSimulator.CoreSimulatorService simdiskimaged 2>/dev/null
sleep 20
xcrun simctl list runtimes >/dev/null 2>&1
done
echo "::error::Failed after 3 attempts: $*"
exit 1
}
xcrun simctl list runtimes >/dev/null 2>&1
retry xcodebuild -downloadPlatform iOS -exportPath "$DL_DIR"
# Find the dmg; its name embeds a build number that changes.
DMG="$(find "$DL_DIR" -name '*.dmg' | head -n1)"
[ -n "$DMG" ] || { echo "::error::No dmg in $DL_DIR"; exit 1; }
retry xcodebuild -importPlatform "$DMG"
- 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@v4
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}}
# Archive unsigned: this job has no certs/profiles (secrets), and the iOS
# device SDK forbids ad-hoc signing, so real signing happens in the trusted
# publish workflow (ios.preview.publish.yml). Do NOT pass
# CODE_SIGN_ENTITLEMENTS="" -- each target must keep its own entitlements so
# the archive records them; blanking them dropped the App Group and crashed
# MMKV on launch.
- name: Archive (unsigned)
run: |
set -euo pipefail
xcodebuild \
-workspace apps/mobile/ios/Notesnook.xcworkspace \
-scheme Notesnook \
-configuration Release \
-sdk iphoneos \
-destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/Notesnook.xcarchive" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" \
archive
- name: Stage build artifact
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/artifact"
tar -czf "$RUNNER_TEMP/artifact/Notesnook.xcarchive.tar.gz" \
-C "$RUNNER_TEMP" Notesnook.xcarchive
# Carry the PR context forward; workflow_run cannot see it reliably for forks.
{
echo "PR_NUMBER=${{ github.event.pull_request.number }}"
echo "HEAD_SHA=${{ github.event.pull_request.head.sha }}"
} > "$RUNNER_TEMP/artifact/pr-meta.env"
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: ios-preview-build
path: ${{ runner.temp }}/artifact
if-no-files-found: error
retention-days: 1

View File

@@ -0,0 +1,173 @@
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: Setup iOS Platform
run: |
xcodebuild -downloadPlatform iOS -exportPath ~/Downloads
xcodebuild -importPlatform ~/Downloads/iphonesimulator_26.1_23B86.dmg
- 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

@@ -1,222 +0,0 @@
name: Notesnook iOS Preview Publish
# TRUSTED stage. Runs via `workflow_run` after the build workflow finishes, so
# it has the base repo's secrets and a write-scoped token. It checks out the
# BASE repository (never fork code), downloads the unsigned archive the build
# produced, then signs + distributes it and posts the PR comment. Signing is
# done directly with `codesign` on the prebuilt archive -- no app build phases
# run here, so fork code is never executed in this trusted context.
#
# Split into two jobs on purpose: signing needs macOS, but the Firebase
# distribution action is a Docker container action and only runs on Linux.
on:
workflow_run:
workflows: ["Notesnook iOS Preview Build"]
types: [completed]
jobs:
export:
# Only publish previews for builds that actually succeeded.
if: github.event.workflow_run.conclusion == 'success'
runs-on: macos-26
timeout-minutes: 30
outputs:
pr_number: ${{ steps.meta.outputs.PR_NUMBER }}
head_sha: ${{ steps.meta.outputs.HEAD_SHA }}
steps:
- name: Checkout base repo (trusted)
uses: actions/checkout@v5
# No `ref` -> checks out the default branch, i.e. trusted base code.
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: "26.1.1"
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: ios-preview-build
path: ${{ runner.temp }}/artifact
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Load PR metadata & extract archive
id: meta
run: |
set -euo pipefail
cat "$RUNNER_TEMP/artifact/pr-meta.env" >> "$GITHUB_OUTPUT"
tar -xzf "$RUNNER_TEMP/artifact/Notesnook.xcarchive.tar.gz" -C "$RUNNER_TEMP"
- name: Import signing certificate
uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE_P12 }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_P12_PASSWORD }}
- name: Install provisioning profiles
env:
PROFILE_APP: ${{ secrets.APPLE_MOBILE_PROVISION_ADHOC_APP }}
PROFILE_SHARE: ${{ secrets.APPLE_MOBILE_PROVISION_ADHOC_SHARE }}
PROFILE_WIDGET: ${{ secrets.APPLE_MOBILE_PROVISION_ADHOC_WIDGET }}
run: |
set -euo pipefail
PROFILE_DIR="$HOME/Library/MobileDevice/Provisioning Profiles"
mkdir -p "$PROFILE_DIR"
i=0
for p in "$PROFILE_APP" "$PROFILE_SHARE" "$PROFILE_WIDGET"; do
echo "$p" | base64 --decode > "$PROFILE_DIR/preview-$i.mobileprovision"
i=$((i + 1))
done
# Sign the unsigned archive by hand. `xcodebuild -exportArchive` re-signs
# from the archive's `archived-expanded-entitlements.xcent`, which an
# unsigned archive does not contain, so it dropped the App Group and the
# app crashed on launch. Instead we sign each bundle directly, taking the
# entitlements from each provisioning profile (exactly what Apple
# provisioned -- App Group, keychain groups with the right team prefix).
# Runs on macOS but executes NO fork build phases, so it stays trusted.
- name: Sign IPA
run: |
set -euo pipefail
PROFILES="$HOME/Library/MobileDevice/Provisioning Profiles"
APP="$(find "$RUNNER_TEMP/Notesnook.xcarchive/Products/Applications" -maxdepth 1 -name '*.app' | head -n1)"
[ -n "$APP" ] || { echo "::error::No .app in archive"; exit 1; }
# Single Apple Distribution identity from the imported keychain.
IDENTITY="$(security find-identity -v -p codesigning | awk '/Apple Distribution/{print $2; exit}')"
[ -n "$IDENTITY" ] || { echo "::error::No Apple Distribution identity found"; exit 1; }
echo "Signing identity: $IDENTITY"
TMP="$RUNNER_TEMP/sign"; mkdir -p "$TMP"
# Decode a profile's bundle id (application-identifier minus team prefix).
profile_bundle_id() {
security cms -D -i "$1" -o "$TMP/_p.plist" 2>/dev/null
local appid; appid="$(plutil -extract Entitlements.application-identifier raw -o - "$TMP/_p.plist")"
echo "${appid#*.}"
}
# Find the profile matching a bundle id; echoes its path.
profile_for() {
local want="$1" p
for p in "$PROFILES"/*.mobileprovision; do
[ -e "$p" ] || continue
[ "$(profile_bundle_id "$p")" = "$want" ] && { echo "$p"; return 0; }
done
return 1
}
# Embed the matching profile and sign a bundle with the profile's entitlements.
sign_bundle() {
local bundle="$1" bid prof ent
bid="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$bundle/Info.plist")"
prof="$(profile_for "$bid")" || { echo "::error::No provisioning profile for $bid"; exit 1; }
ent="$TMP/ent-$bid.plist"
security cms -D -i "$prof" -o "$TMP/_p.plist"
plutil -extract Entitlements xml1 -o "$ent" "$TMP/_p.plist"
cp "$prof" "$bundle/embedded.mobileprovision"
echo "Signing $(basename "$bundle") ($bid)"
codesign --force --sign "$IDENTITY" --entitlements "$ent" "$bundle"
}
# Sign inside-out: nested code first, then extensions, then the app.
if [ -d "$APP/Frameworks" ]; then
find "$APP/Frameworks" -maxdepth 1 \( -name '*.framework' -o -name '*.dylib' \) -print0 \
| while IFS= read -r -d '' f; do codesign --force --sign "$IDENTITY" "$f"; done
fi
if [ -d "$APP/PlugIns" ]; then
for appex in "$APP/PlugIns"/*.appex; do
[ -e "$appex" ] || continue
if [ -d "$appex/Frameworks" ]; then
find "$appex/Frameworks" -maxdepth 1 \( -name '*.framework' -o -name '*.dylib' \) -print0 \
| while IFS= read -r -d '' f; do codesign --force --sign "$IDENTITY" "$f"; done
fi
sign_bundle "$appex"
done
fi
sign_bundle "$APP"
codesign --verify --deep --strict --verbose=2 "$APP"
# Package into an IPA.
rm -rf "$TMP/Payload"; mkdir -p "$TMP/Payload"
cp -R "$APP" "$TMP/Payload/"
( cd "$TMP" && zip -qry "$RUNNER_TEMP/Notesnook.ipa" Payload )
# Fail fast if the App Group didn't make it into the signed app -- its
# absence is the launch crash (MMKV nil group path). Never distribute
# a build without it.
echo "::group::Signed app entitlements"
codesign -d --entitlements :- "$APP" 2>/dev/null || true
echo "::endgroup::"
codesign -d --entitlements :- "$APP" 2>/dev/null | grep -q "group.org.streetwriters.notesnook" \
|| { echo "::error::Signed app is missing the App Group entitlement; it would crash on launch."; exit 1; }
echo "App Group entitlement present."
- name: Upload signed IPA
uses: actions/upload-artifact@v4
with:
name: ios-preview-ipa
path: ${{ runner.temp }}/Notesnook.ipa
if-no-files-found: error
retention-days: 1
# Must be Linux: wzieba/Firebase-Distribution-Github-Action is a Docker
# container action, which GitHub only supports on Linux runners.
distribute:
needs: export
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Download signed IPA
uses: actions/download-artifact@v4
with:
name: ios-preview-ipa
path: ${{ runner.temp }}
- name: Publish to Firebase
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: ${{ runner.temp }}/Notesnook.ipa
releaseNotes: Preview for https://github.com/${{ github.repository }}/pull/${{ needs.export.outputs.pr_number }}
- name: Post or update PR comment
uses: actions/github-script@v7
env:
preview_url: ${{ steps.firebase-output.outputs.TESTING_URI }}
PR_NUMBER: ${{ needs.export.outputs.pr_number }}
HEAD_SHA: ${{ needs.export.outputs.head_sha }}
with:
script: |
const marker = '<!-- ios-preview-comment -->';
const prNumber = Number(process.env.PR_NUMBER);
if (!prNumber) return;
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.HEAD_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

@@ -9,7 +9,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v3
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -22,7 +22,7 @@ jobs:
id-token: write
steps:
- name: Check out the repo
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -22,7 +22,7 @@ jobs:
id-token: write
steps:
- name: Check out the repo
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Collect package metadata
id: package_metadata

View File

@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -19,7 +19,7 @@ jobs:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -27,9 +27,7 @@ jobs:
uses: ./.github/actions/setup-node-with-cache
- name: Install dependencies
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=web
run: npm ci
- name: Build web
run: npm run build:web

View File

@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -37,7 +37,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}
@@ -66,7 +66,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}

View File

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

51
agent.md Normal file
View File

@@ -0,0 +1,51 @@
## Notesnook Design Agent
### Purpose
- Implement Figma designs from user-provided links into the Notesnook app with high visual fidelity.
- Use related components and files provided by the user as the source of truth for local patterns.
- Preserve behavior, navigation, and existing data flow while updating UI.
### When To Use This Agent
- Use this agent when the task is design-to-code for app UI.
- Prefer this agent over a general coding agent when the user provides a Figma link and asks for redesign implementation.
### Scope
- Primary focus: apps/mobile.
- Secondary scope: shared UI dependencies needed for the design update (tokens, icons, strings, and generated assets).
- Do not perform unrelated refactors.
### Tool Preferences
- Prefer Figma design-context tools to extract layout, spacing rhythm, typography intent, and asset references from the exact node.
- Prefer direct workspace edits and targeted validation for touched files.
- Avoid introducing new UI frameworks or web-specific styling approaches.
### Repo Conventions
- Use theme and token systems first: useThemeColors, spacing/radius constants, and shared typography primitives.
- Reuse existing UI primitives and patterns before creating new abstractions.
- Keep dark and light mode compatibility by default.
- Keep changes minimal, isolated, and review-friendly.
### Icon Conventions
- Add new icon SVGs to packages/icons/svgs with lowercase kebab-case names.
- Regenerate icon assets from apps/mobile using npx react-native-nano-icons.
- Use generated icon names through AppIcon with icon family notesnook where applicable.
### Figma To Code Workflow
1. Read the exact Figma node from the provided link.
2. Compare against existing repo patterns and referenced components.
3. Implement with React Native conventions already used in the app.
4. Preserve behavior and interactions unless explicitly changed.
5. Validate compile and lint state for edited files.
### Output Expectations
- Provide a concise summary of what changed.
- List modified files and any generated artifacts.
- Call out follow-up steps only when needed.

View File

@@ -17,9 +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 { test, expect } from "@nn/test";
import { test } from "@nn/test";
import { gt, lt } from "semver";
import { AppModel } from "../../web/__e2e__/models/app.model.js";
import { expect } from "playwright/test";
test.extend({ options: { version: "3.0.0" } })(
"update starts downloading if version is outdated",

View File

@@ -17,7 +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 { mergeTests, test } from "@playwright/test";
import { mergeTests } from "@playwright/test";
import { test } from "@playwright/test";
import type { CommonFixtures, CommonWorkerFixtures } from "./common-fixtures";
import { commonFixtures } from "./common-fixtures";
import { platformTest } from "./platform-fixtures";

View File

@@ -21,7 +21,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { AppContext, buildAndLaunchApp, TestOptions } from "./utils";
import path from "path";
import { version } from "../../package.json";
import type { ElectronApplication, Page } from "@playwright/test";
import type { Browser, ElectronApplication, Page } from "@playwright/test";
import type { TraceViewerFixtures } from "./trace-viewer-fixtures";
import { traceViewerFixtures } from "./trace-viewer-fixtures";
import { PageTestFixtures, PageWorkerFixtures } from "./page-test-api";

View File

@@ -1,35 +1,42 @@
/* eslint-disable header/header */
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/*
This file is part of the Notesnook project (https://notesnook.com/)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
/// @ts-ignore
import { oop, client } from "playwright-core/lib/coreBundle";
Copyright (C) 2023 Streetwriters (Private) Limited
export type TestModeName = "default" | "driver";
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.
const { start } = oop;
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/>.
*/
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-ignore
import { start } from "playwright-core/lib/outofprocess";
// @ts-ignore
import type { Playwright } from "playwright-core/lib/client/playwright";
export type TestModeName =
| "default"
| "driver"
| "service"
| "service2"
| "wsl";
interface TestMode {
setup(): Promise<client.Playwright>;
setup(): Promise<Playwright>;
teardown(): Promise<void>;
}
export class DriverTestMode implements TestMode {
private _impl: { playwright: client.Playwright; stop: () => Promise<void> };
private _impl: { playwright: Playwright; stop: () => Promise<void> };
async setup() {
this._impl = await start({

View File

@@ -20,11 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { execSync } from "child_process";
import { cp } from "fs/promises";
import path, { join, resolve } from "path";
import {
_electron as electron,
ElectronApplication,
Page
} from "@playwright/test";
import { _electron as electron, ElectronApplication, Page } from "playwright";
import { existsSync } from "fs";
import { mkdir, writeFile } from "node:fs/promises";
@@ -34,7 +30,7 @@ const root = path.resolve(__dirname, "..", "..");
const SOURCE_DIR = resolve(root, "output", productName);
export interface AppContext {
app: ElectronApplication;
app: import("playwright").ElectronApplication;
userDataDir: string;
outputDir: string;
relaunch: () => Promise<void>;
@@ -42,7 +38,6 @@ export interface AppContext {
export interface TestOptions {
version: string;
args?: string[];
config?: Record<string, unknown>;
}
@@ -73,8 +68,7 @@ export async function buildAndLaunchApp(
const { app } = await launchApp(
executablePath,
userDataDir,
options?.version,
options?.args
options?.version
);
const ctx: AppContext = {
app,
@@ -84,8 +78,7 @@ export async function buildAndLaunchApp(
const { app } = await launchApp(
executablePath,
userDataDir,
options?.version,
options?.args
options?.version
);
ctx.app = app;
ctx.userDataDir = userDataDir;
@@ -97,12 +90,11 @@ export async function buildAndLaunchApp(
async function launchApp(
executablePath: string,
userDataDir: string,
version?: string,
args: string[] = []
version?: string
) {
const app = await electron.launch({
executablePath,
args: IS_DEBUG ? [...args] : ["--hidden", ...args],
args: IS_DEBUG ? [] : ["--hidden"],
baseURL: "https://app.notesnook.com",
acceptDownloads: true,
env: {
@@ -158,7 +150,6 @@ export async function buildApp(version?: string) {
stdio: IS_DEBUG ? "inherit" : "ignore",
env: {
...process.env,
CSC_IDENTITY_AUTO_DISCOVERY: "false",
NOTESNOOK_STAGING: "true",
NN_PRODUCT_NAME: productName,
NN_APP_ID: `com.notesnook.test.${productName}`,

View File

@@ -17,65 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { test, expect } from "@nn/test";
import type { ElectronApplication } from "@playwright/test";
async function getMainWindowState(app: ElectronApplication) {
return await app.evaluate((window) => {
const { BrowserWindow } = window;
const mainWindow = BrowserWindow.getAllWindows()[0];
if (!mainWindow) throw new Error("Main window not found");
return {
isMinimized: mainWindow.isMinimized(),
isVisible: mainWindow.isVisible()
};
});
}
import { test } from "@nn/test";
test("make sure app loads", async ({ page }) => {
await page.waitForSelector(".ProseMirror");
});
test("hidden launch minimizes when tray is disabled", async ({
launchElectronApp,
options
}) => {
const app = await launchElectronApp({
version: options.version,
args: ["--hidden"],
config: {
desktopSettings: {
minimizeToSystemTray: false,
closeToSystemTray: false
}
}
});
const page = await app.firstWindow();
await page.waitForSelector(".ProseMirror");
const state = await getMainWindowState(app);
expect(state.isMinimized).toBe(true);
});
test("hidden launch does not minimize when close-to-tray is enabled", async ({
launchElectronApp,
options
}) => {
const app = await launchElectronApp({
version: options.version,
args: ["--hidden"],
config: {
desktopSettings: {
minimizeToSystemTray: false,
closeToSystemTray: true
}
}
});
const page = await app.firstWindow();
await page.waitForSelector(".ProseMirror");
const state = await getMainWindowState(app);
expect(state.isMinimized).toBe(false);
});

View File

@@ -203,12 +203,11 @@ module.exports = {
toolsets: {
appimage: "1.0.2"
},
snapcraft: {
base: "core24",
core24: {
confinement: "strict",
autoStart: false
}
snap: {
autoStart: false,
confinement: "strict",
allowNativeWayland: true,
base: "core22"
},
extraResources: ["app-update.yml", "./assets/**"],
extraMetadata: {

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.4.6",
"version": "3.4.0-beta.1",
"appAppleId": "1544027013",
"private": true,
"main": "./dist/cjs/index.js",
@@ -54,18 +54,18 @@
"zod": "3.24.3"
},
"devDependencies": {
"@playwright/test": "1.61.1",
"@playwright/test": "^1.58.2",
"@streetwriters/kysely": "^0.27.4",
"@types/node": "22.15.3",
"@types/yargs": "^17.0.33",
"chokidar": "^4.0.3",
"electron": "^37.10.3",
"electron": "^37.0.0",
"electron-builder": "^26.8.1",
"esbuild": "0.21.5",
"fkill": "^10.0.3",
"node-abi": "^4.5.0",
"node-gyp-build": "^4.8.4",
"playwright-core": "1.61.1",
"playwright": "1.58.2",
"prebuildify": "^6.0.1",
"slugify": "1.6.6",
"tree-kill": "^1.2.2",
@@ -86,7 +86,7 @@
"bundle": "esbuild electron=./src/main.ts ./src/preload.ts --external:electron --external:fsevents --external:better-sqlite3-multiple-ciphers --external:sodium-native --bundle --outdir=./build --platform=node --tsconfig=tsconfig.json --define:MAC_APP_STORE=false --define:RELEASE=true",
"bundle:mas": "esbuild electron=./src/main.ts ./src/preload.ts --minify --external:electron --external:fsevents --bundle --outdir=./build --platform=node --tsconfig=tsconfig.json --define:MAC_APP_STORE=true --define:RELEASE=true",
"postinstall": "patch-package",
"test": "playwright test --tsconfig __tests__/tsconfig.json --project notesnook-desktop"
"test": "playwright test --tsconfig tsconfig.json"
},
"author": {
"name": "Streetwriters (Private) Limited",

View File

@@ -1,24 +0,0 @@
diff --git a/node_modules/@playwright/test/node_modules/playwright-core/lib/coreBundle.js b/node_modules/@playwright/test/node_modules/playwright-core/lib/coreBundle.js
index 7d8468f..ab252ef 100644
--- a/node_modules/@playwright/test/node_modules/playwright-core/lib/coreBundle.js
+++ b/node_modules/@playwright/test/node_modules/playwright-core/lib/coreBundle.js
@@ -16010,6 +16010,7 @@ var init_validator = __esm({
args: tOptional(tArray(tString)),
chromiumSandbox: tOptional(tBoolean),
cwd: tOptional(tString),
+ baseURL: tOptional(tString),
env: tOptional(tArray(tType("NameValue"))),
timeout: tFloat,
acceptDownloads: tOptional(tEnum(["accept", "deny", "internal-browser-default"])),
diff --git a/node_modules/@playwright/test/node_modules/playwright-core/types/types.d.ts b/node_modules/@playwright/test/node_modules/playwright-core/types/types.d.ts
index 0bff9d3..d195697 100644
--- a/node_modules/@playwright/test/node_modules/playwright-core/types/types.d.ts
+++ b/node_modules/@playwright/test/node_modules/playwright-core/types/types.d.ts
@@ -21813,6 +21813,7 @@ export interface Electron {
* Additional arguments to pass to the application when launching. You typically pass the main script name here.
*/
args?: Array<string>;
+ baseURL?: string;
/**
* If specified, artifacts (traces, videos, downloads, HAR files, etc.) are saved into this directory. The directory

View File

@@ -0,0 +1,24 @@
diff --git a/node_modules/playwright-core/lib/protocol/validator.js b/node_modules/playwright-core/lib/protocol/validator.js
index b36c979..9e8ca15 100644
--- a/node_modules/playwright-core/lib/protocol/validator.js
+++ b/node_modules/playwright-core/lib/protocol/validator.js
@@ -2536,6 +2536,7 @@ import_validatorPrimitives.scheme.ElectronLaunchParams = (0, import_validatorPri
executablePath: (0, import_validatorPrimitives.tOptional)(import_validatorPrimitives.tString),
args: (0, import_validatorPrimitives.tOptional)((0, import_validatorPrimitives.tArray)(import_validatorPrimitives.tString)),
cwd: (0, import_validatorPrimitives.tOptional)(import_validatorPrimitives.tString),
+ baseURL: (0, import_validatorPrimitives.tOptional)(import_validatorPrimitives.tString),
env: (0, import_validatorPrimitives.tOptional)((0, import_validatorPrimitives.tArray)((0, import_validatorPrimitives.tType)("NameValue"))),
timeout: import_validatorPrimitives.tFloat,
acceptDownloads: (0, import_validatorPrimitives.tOptional)((0, import_validatorPrimitives.tEnum)(["accept", "deny", "internal-browser-default"])),
diff --git a/node_modules/playwright-core/types/types.d.ts b/node_modules/playwright-core/types/types.d.ts
index f6e6165..4efd4d4 100644
--- a/node_modules/playwright-core/types/types.d.ts
+++ b/node_modules/playwright-core/types/types.d.ts
@@ -19233,6 +19233,7 @@ export interface Electron {
* Additional arguments to pass to the application when launching. You typically pass the main script name here.
*/
args?: Array<string>;
+ baseURL?: string;
/**
* Toggles bypassing page's Content-Security-Policy. Defaults to `false`.

View File

@@ -46,7 +46,7 @@ const config: Config<PlaywrightWorkerOptions & PlaywrightTestOptions> = {
globalTimeout: 5400000,
workers: process.env.CI ? 1 : undefined,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
retries: process.env.CI ? 3 : 0,
reporter: process.env.CI
? [["dot"], ["json", { outputFile: path.join(outputDir, "report.json") }]]
: "line",

View File

@@ -33,38 +33,17 @@ function generateId() {
return Math.random().toString(36).slice(2);
}
function getStreamOrThrow(id: string, operation: "write" | "close") {
const stream = activeStreams.get(id);
if (!stream) {
throw new Error(
`Backup stream not found during ${operation}. The stream may have already been closed or was never opened. Stream id: ${id}`
);
}
return stream;
}
export const backupsRouter = t.router({
open: t.procedure
.input(z.object({ filename: z.string() }))
.mutation(({ input }) => {
const { filename } = input;
if (!filename.trim()) {
throw new Error("Invalid backup filename: filename cannot be empty.");
}
if (filename.includes(path.sep) || filename.includes("\\"))
throw new Error(
`Invalid backup filename: expected a plain file name without path separators, received "${filename}".`
);
throw new Error("Invalid filename");
const resolvedBackupDir = resolvePath(config.backupDirectory);
const backupPath = path.resolve(resolvedBackupDir, filename);
const relativeBackupPath = path.relative(resolvedBackupDir, backupPath);
if (
relativeBackupPath.startsWith("..") ||
path.isAbsolute(relativeBackupPath)
)
throw new Error(
`Invalid backup filename: resolved path "${backupPath}" is outside the configured backup directory "${resolvedBackupDir}". The configured backup directory may be invalid.`
);
if (!backupPath.startsWith(resolvedBackupDir))
throw new Error("Invalid filename");
mkdirSync(resolvedBackupDir, { recursive: true });
const stream = createWriteStream(backupPath, { encoding: "utf-8" });
@@ -76,7 +55,8 @@ export const backupsRouter = t.router({
write: t.procedure
.input(z.object({ id: z.string(), chunk: z.string() }))
.mutation(({ input }) => {
const stream = getStreamOrThrow(input.id, "write");
const stream = activeStreams.get(input.id);
if (!stream) throw new Error("Stream not found");
return new Promise<void>((resolve, reject) => {
stream.write(Buffer.from(input.chunk, "base64"), (err) =>
err ? reject(err) : resolve()
@@ -87,7 +67,8 @@ export const backupsRouter = t.router({
close: t.procedure
.input(z.object({ id: z.string() }))
.mutation(({ input }) => {
const stream = getStreamOrThrow(input.id, "close");
const stream = activeStreams.get(input.id);
if (!stream) throw new Error("Stream not found");
return new Promise<void>((resolve) => {
stream.end(() => {
activeStreams.delete(input.id);

View File

@@ -33,7 +33,7 @@ import { config, DesktopIntegration } from "../utils/config";
import { bringToFront } from "../utils/bring-to-front";
import { getTheme, setTheme, Theme } from "../utils/theme";
import { existsSync } from "fs";
import { normalizePathString, resolvePath } from "../utils/resolve-path";
import { resolvePath } from "../utils/resolve-path";
import { observable } from "@trpc/server/observable";
import { AssetManager } from "../utils/asset-manager";
import { isFlatpak, isPortable, isSnap } from "../utils";
@@ -60,13 +60,7 @@ export const osIntegrationRouter = t.router({
isFlatpak: t.procedure.query(() => isFlatpak()),
isSnap: t.procedure.query(() => isSnap()),
isPortable: t.procedure.query(() => isPortable()),
backupDirectory: t.procedure.query(() => {
const backupDirectory = normalizePathString(config.backupDirectory);
if (backupDirectory !== config.backupDirectory) {
config.backupDirectory = backupDirectory;
}
return backupDirectory;
}),
backupDirectory: t.procedure.query(() => config.backupDirectory),
zoomFactor: t.procedure.query(() => config.zoomFactor),
setZoomFactor: t.procedure.input(z.number()).mutation(({ input: factor }) => {

View File

@@ -40,7 +40,6 @@ import { disableCustomDns, enableCustomDns } from "./utils/custom-dns";
import { Messages, setI18nGlobal } from "@notesnook/intl";
import { i18n } from "@lingui/core";
import { PATHS } from "./constants";
import { normalizePathString } from "./utils/resolve-path";
const locale =
process.env.NODE_ENV === "development"
@@ -92,17 +91,6 @@ async function createWindow() {
const cliOptions = await parseArguments(process.argv);
setTheme(getTheme());
// this workaround is necessary because macos doesn't support
// the --hidden flag when launching the app on startup
if (
process.platform === "darwin" &&
app.getLoginItemSettings().wasOpenedAtLogin &&
config.desktopSettings.autoStart &&
config.desktopSettings.startMinimized
) {
cliOptions.hidden = true;
}
const mainWindowState = new WindowState({});
const mainWindow = new BrowserWindow({
show: !cliOptions.hidden,
@@ -152,13 +140,7 @@ async function createWindow() {
mainWindow.setMenuBarVisibility(false);
mainWindowState.manage(mainWindow);
if (
cliOptions.hidden &&
!(
config.desktopSettings.minimizeToSystemTray ||
config.desktopSettings.closeToSystemTray
)
)
if (cliOptions.hidden && !config.desktopSettings.minimizeToSystemTray)
mainWindow.minimize();
await mainWindow.webContents.loadURL(`${createURL(cliOptions, "/")}`);
@@ -181,14 +163,6 @@ async function createWindow() {
);
mainWindow.webContents.session.setProxy({ proxyRules: config.proxyRules });
mainWindow.on("show", () =>
/**
* We may set `skipTaskbar` to true at startup.
* This also removes the window from the Alt-Tab switcher.
* To fix that, whenever the app is shown, we set `skipTaskbar` to false.
*/
mainWindow.setSkipTaskbar(false)
);
mainWindow.once("closed", () => {
globalThis.window = null;
});
@@ -320,7 +294,7 @@ async function migrateBackupDirectory() {
`localStorage.getItem("backupStorageLocation")`
);
if (!oldPath || oldPath === PATHS.backupsDirectory) return;
config.backupDirectory = normalizePathString(oldPath);
config.backupDirectory = oldPath;
} catch (e) {
console.error("Failed to migrate backup directory", e);
const pressedButton = dialog.showMessageBoxSync(globalThis.window, {

View File

@@ -20,30 +20,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { app } from "electron";
import { isAbsolute, join } from "path";
export function normalizePathString(_path: string) {
let normalizedPath = _path.trim();
try {
const parsedPath = JSON.parse(normalizedPath);
if (typeof parsedPath === "string") normalizedPath = parsedPath;
} catch {
// ignore invalid JSON and keep the original path string
}
if (normalizedPath === "~") return app.getPath("home");
if (/^~[\\/]/.test(normalizedPath)) {
return join(app.getPath("home"), normalizedPath.slice(2));
}
return normalizedPath;
}
export function resolvePath(_path: string) {
const normalizedPath = normalizePathString(_path);
if (isAbsolute(normalizedPath)) return normalizedPath;
if (isAbsolute(_path)) return _path;
return join(
...normalizedPath.split("/").map((segment) => {
..._path.split("/").map((segment) => {
let resolved = segment;
try {
resolved = app.getPath(resolved as any);

View File

@@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v2
with:
persist-credentials: false

View File

@@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v2
with:
persist-credentials: false

View File

@@ -0,0 +1,9 @@
{
"iconSets": [
{
"inputDir": "../../packages/icons/svgs",
"fontFamily": "notesnook-icons",
"outputDir": "./fonts"
}
]
}

View File

@@ -140,7 +140,7 @@ android {
if (project.hasProperty("prBuildNumber")) {
versionCode Integer.parseInt(prBuildNumber())
} else {
versionCode 3116
versionCode 3108
}
versionName getNpmVersion()
testBuildType System.getProperty('testBuildType', 'debug')
@@ -233,9 +233,6 @@ dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.0.0")
// Lets the widgets push their list data straight into the RemoteViews on every API level,
// instead of the deprecated RemoteViewsService adapter (which needs API 31 to do natively).
implementation("androidx.core:core-remoteviews:1.0.0")
implementation("androidx.core:core-splashscreen:1.0.0")
implementation 'androidx.multidex:multidex:2.0.1'

View File

@@ -86,9 +86,8 @@
android:name=".NotePreviewWidget"
android:exported="false"
android:label="@string/note">
<intent-filter>
<intent-filter>"
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="android.appwidget.action.APPWIDGET_RESTORED" />
</intent-filter>
<meta-data
@@ -96,15 +95,6 @@
android:resource="@xml/note_widget_info" />
</receiver>
<receiver
android:name=".WidgetTimeChangeReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.TIME_SET" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
</intent-filter>
</receiver>
<receiver
android:name=".ReminderWidgetProvider"
android:exported="false"
@@ -240,6 +230,11 @@
</intent-filter>
</service>
<service
android:name=".ReminderViewsService"
android:exported="true"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"

View File

@@ -5,11 +5,15 @@ import android.appwidget.AppWidgetManager;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import android.widget.RemoteViews;
import com.facebook.react.ReactActivity;
import com.facebook.react.ReactActivityDelegate;
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
import com.facebook.react.defaults.DefaultReactActivityDelegate;
import com.google.gson.Gson;
import com.streetwriters.notesnook.datatypes.Note;
public class NotePreviewConfigureActivity extends ReactActivity {
@@ -36,32 +40,18 @@ public class NotePreviewConfigureActivity extends ReactActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(null);
activity = this;
readAppWidgetId(getIntent());
}
/**
* We launch as singleTask, so configuring a second widget while this screen is still alive
* arrives here rather than in onCreate(). Without this the activity would keep writing to
* whichever widget it happened to be opened for first.
*/
@Override
public void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
activity = this;
readAppWidgetId(intent);
}
private void readAppWidgetId(Intent intent) {
Bundle extras = intent != null ? intent.getExtras() : null;
int appWidgetId = extras != null
? extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
: AppWidgetManager.INVALID_APPWIDGET_ID;
NotePreviewConfigureActivity.appWidgetId = appWidgetId;
Intent intent = getIntent();
Bundle extras = intent.getExtras();
int appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID;
if (extras != null) {
appWidgetId = extras.getInt(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID);
NotePreviewConfigureActivity.appWidgetId = appWidgetId;
}
Intent resultValue = new Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
setResult(Activity.RESULT_CANCELED, resultValue);
activity = this;
}
public static void saveAndFinish(Context context) {

View File

@@ -1,5 +1,6 @@
package com.streetwriters.notesnook;
import android.app.ActivityOptions;
import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
@@ -7,125 +8,51 @@ import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.view.View;
import android.widget.RemoteViews;
import com.google.gson.Gson;
import com.streetwriters.notesnook.datatypes.Note;
import java.util.HashSet;
import java.util.Set;
public class NotePreviewWidget extends AppWidgetProvider {
static String OpenNoteId = "com.streetwriters.notesnook.OpenNoteId";
static void updateAppWidget(Context context, AppWidgetManager appWidgetManager,
int appWidgetId) {
String data = context.getSharedPreferences(WidgetUtils.PREFERENCES, Context.MODE_PRIVATE).getString(String.valueOf(appWidgetId), "");
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.note_widget);
Note note = WidgetUtils.parseNote(data);
if (note == null) {
// Either the widget was never configured, or we lost the note it pointed at (ids
// reassigned, data cleared). Point it back at the picker rather than leaving the user
// with an inert widget they can only fix by deleting and re-adding it.
views.setTextViewText(R.id.widget_title, context.getString(R.string.widget_note_unconfigured_title));
views.setTextViewText(R.id.widget_body, context.getString(R.string.widget_note_unconfigured_body));
views.setOnClickPendingIntent(R.id.open_note, getConfigurePendingIntent(context, appWidgetId));
appWidgetManager.updateAppWidget(appWidgetId, views);
String data = context.getSharedPreferences("appPreview", Context.MODE_PRIVATE).getString(String.valueOf(appWidgetId), "");
if (data.isEmpty()) {
return;
}
Gson gson = new Gson();
Note note = gson.fromJson(data, Note.class);
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.note_widget);
views.setTextViewText(R.id.widget_title, note.getTitle());
views.setTextViewText(R.id.widget_body, note.getHeadline());
// Once the user shrinks the widget down to a single row there is no room for the preview
// text, and a clipped half-line of it looks like a rendering glitch.
views.setViewVisibility(R.id.widget_body,
hasRoomForBody(appWidgetManager, appWidgetId) ? View.VISIBLE : View.GONE);
Intent intent = new Intent(context, MainActivity.class);
intent.putExtra(OpenNoteId, note.getId());
intent.setAction(Intent.ACTION_VIEW);
intent.putExtra(RCTNNativeModule.IntentType, "OpenNote");
intent.setData(Uri.parse("nn://note/" + note.getId()));
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, WidgetUtils.getActivityOptionsBundle());
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, getActivityOptionsBundle());
views.setOnClickPendingIntent(R.id.open_note, pendingIntent);
appWidgetManager.updateAppWidget(appWidgetId, views);
}
/**
* Reopens the configure screen for this widget. The launcher's own "reconfigure" gesture is
* hard to discover and not offered by every launcher, so an unconfigured widget needs its own
* way back in.
*/
private static PendingIntent getConfigurePendingIntent(Context context, int appWidgetId) {
Intent intent = new Intent(context, NotePreviewConfigureActivity.class);
intent.setAction(AppWidgetManager.ACTION_APPWIDGET_CONFIGURE);
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
// PendingIntent equality ignores extras, so the widget id has to be the request code for
// each widget to get its own.
return PendingIntent.getActivity(context, appWidgetId, intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE,
WidgetUtils.getActivityOptionsBundle());
}
/**
* Height below which the note preview text is dropped, leaving just the title.
*/
private static final int MIN_HEIGHT_FOR_BODY_DP = 70;
private static boolean hasRoomForBody(AppWidgetManager appWidgetManager, int appWidgetId) {
Bundle options = appWidgetManager.getAppWidgetOptions(appWidgetId);
if (options == null) return true;
int minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT);
// Not reported yet (the widget has just been placed): assume there is room.
return minHeight <= 0 || minHeight >= MIN_HEIGHT_FOR_BODY_DP;
}
@Override
public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle newOptions) {
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions);
// This used to do nothing at all, so resizing the widget left it rendered for its old size.
updateAppWidget(context, appWidgetManager, appWidgetId);
}
/**
* The note shown by each widget is stored in the "appPreview" preferences under its widget id.
* When the system restores our widgets it hands out fresh ids, so unless we move the stored
* notes over to the new ids the widgets are left permanently blank with no way to recover
* other than removing and re-adding them.
*
* AppWidgetProvider calls onUpdate() with the new ids right after this, which re-renders them.
*/
@Override
public void onRestored(Context context, int[] oldWidgetIds, int[] newWidgetIds) {
super.onRestored(context, oldWidgetIds, newWidgetIds);
if (oldWidgetIds == null || newWidgetIds == null) return;
int count = Math.min(oldWidgetIds.length, newWidgetIds.length);
SharedPreferences preferences = context.getSharedPreferences("appPreview", Context.MODE_PRIVATE);
// Read everything up front: an old id can collide with the new id of another widget.
String[] notes = new String[count];
Set<String> newKeys = new HashSet<>();
for (int i = 0; i < count; i++) {
notes[i] = preferences.getString(String.valueOf(oldWidgetIds[i]), "");
newKeys.add(String.valueOf(newWidgetIds[i]));
}
SharedPreferences.Editor edit = preferences.edit();
for (int i = 0; i < count; i++) {
String oldKey = String.valueOf(oldWidgetIds[i]);
if (!newKeys.contains(oldKey)) {
edit.remove(oldKey);
}
}
for (int i = 0; i < count; i++) {
if (notes[i].isEmpty()) continue;
edit.putString(String.valueOf(newWidgetIds[i]), notes[i]);
}
edit.apply();
private static Bundle getActivityOptionsBundle() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ActivityOptions activityOptions = ActivityOptions.makeBasic();
activityOptions.setPendingIntentCreatorBackgroundActivityStartMode(
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED);
return activityOptions.toBundle();
} else
return null;
}
@Override

View File

@@ -1,10 +1,12 @@
package com.streetwriters.notesnook;
import android.app.ActivityOptions;
import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.widget.RemoteViews;
@@ -22,10 +24,20 @@ public class NoteWidget extends AppWidgetProvider {
static void setClickIntent(Context context, RemoteViews views) {
Intent intent = new Intent(context, ShareActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, WidgetUtils.getActivityOptionsBundle());
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, getActivityOptionsBundle());
views.setOnClickPendingIntent(R.id.new_note, pendingIntent);
}
private static Bundle getActivityOptionsBundle() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ActivityOptions activityOptions = ActivityOptions.makeBasic();
activityOptions.setPendingIntentCreatorBackgroundActivityStartMode(
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED);
return activityOptions.toBundle();
} else
return null;
}
@Override
public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle newOptions) {
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions);

View File

@@ -12,6 +12,7 @@ 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;
@@ -28,6 +29,7 @@ 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;
@@ -136,7 +138,7 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
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(ReminderWidgetProvider.OpenReminderId, extras.getString(ReminderWidgetProvider.OpenReminderId));
map.putString(ReminderViewsService.OpenReminderId, extras.getString(ReminderViewsService.OpenReminderId));
} else if (Objects.equals(extras.getString(IntentType), "OpenNote")) {
map.putString(NotePreviewWidget.OpenNoteId, extras.getString(NotePreviewWidget.OpenNoteId));
}
@@ -154,8 +156,14 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
@ReactMethod
public void getWidgetNotes(Promise promise) {
SharedPreferences pref = getReactApplicationContext().getSharedPreferences("appPreview", Context.MODE_PRIVATE);
Map<String, ?> map = pref.getAll();
WritableArray arr = Arguments.createArray();
for (Note note : WidgetUtils.getWidgetNotes(getReactApplicationContext()).values()) {
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);
@@ -163,46 +171,36 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
@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 (Note note : WidgetUtils.getWidgetNotes(getReactApplicationContext()).values()) {
if (note.getId().equals(noteId)) {
for(Map.Entry<String,?> entry : map.entrySet()){
String value = (String) entry.getValue();
if (value.contains(noteId)) {
found = true;
break;
}
}
promise.resolve(found);
}
@ReactMethod
public void updateWidgetNote(final String noteId, final String data) {
SharedPreferences pref = getReactApplicationContext().getSharedPreferences(WidgetUtils.PREFERENCES, Context.MODE_PRIVATE);
SharedPreferences pref = getReactApplicationContext().getSharedPreferences("appPreview", Context.MODE_PRIVATE);
Map<String, ?> map = pref.getAll();
SharedPreferences.Editor edit = pref.edit();
List<Integer> ids = new ArrayList<>();
// Match on the note's id, not on the raw JSON containing it somewhere: a note whose body
// happens to mention another note's id is not the same note.
for (Map.Entry<Integer, Note> entry : WidgetUtils.getWidgetNotes(getReactApplicationContext()).entrySet()) {
if (!noteId.equals(entry.getValue().getId())) continue;
edit.putString(String.valueOf(entry.getKey()), data);
ids.add(entry.getKey());
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 (int id : ids) {
NotePreviewWidget.updateAppWidget(mContext, AppWidgetManager.getInstance(mContext), id);
for (String id: ids) {
NotePreviewWidget.updateAppWidget(mContext, AppWidgetManager.getInstance(mContext), Integer.parseInt(id));
}
}
/**
* Redraws every widget from scratch. Needed because the app can be stopped while its widgets
* stay on the home screen: clearing app data empties the store without the widgets ever being
* told, so they keep showing content that is gone until something forces a redraw.
*/
@ReactMethod
public void refreshWidgets() {
WidgetUtils.refreshAll(mContext);
}
@ReactMethod
public void updateReminderWidget() {
AppWidgetManager wm = AppWidgetManager.getInstance(mContext);
@@ -210,8 +208,8 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
for (int id: ids) {
Log.d("Reminders", "Updating" + id);
RemoteViews views = new RemoteViews(mContext.getPackageName(), R.layout.widget_reminders);
// The rows are part of this update, so there is nothing left to invalidate afterwards.
ReminderWidgetProvider.updateAppWidget(mContext, wm, id, views);
wm.notifyAppWidgetViewDataChanged(id, R.id.widget_list_view);
}
}

View File

@@ -0,0 +1,106 @@
package com.streetwriters.notesnook;
import android.app.ActivityOptions;
import android.app.PendingIntent;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.widget.RemoteViewsService;
import android.content.Context;
import android.widget.RemoteViews;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.streetwriters.notesnook.datatypes.Reminder;
import java.util.ArrayList;
import java.util.List;
public class ReminderViewsService extends RemoteViewsService {
static String OpenReminderId = "com.streetwriters.notesnook.OpenReminderId";
@Override
public RemoteViewsFactory onGetViewFactory(Intent intent) {
return new ReminderRemoteViewsFactory(this.getApplicationContext(), intent);
}
}
class ReminderRemoteViewsFactory implements RemoteViewsService.RemoteViewsFactory {
private Context context;
private List<Reminder> reminders;
public ReminderRemoteViewsFactory(Context context, Intent intent) {
this.context = context;
}
@Override
public void onCreate() {
// Initialize reminders list
reminders = new ArrayList<Reminder>();
}
@Override
public void onDataSetChanged() {
reminders.clear();
SharedPreferences preferences = context.getSharedPreferences("appPreview", Context.MODE_PRIVATE);
Gson gson = new Gson();
reminders = gson.fromJson(preferences.getString("remindersList","[]"), new TypeToken<List<Reminder>>(){}.getType());
}
@Override
public void onDestroy() {
reminders.clear();
}
@Override
public int getCount() {
return reminders.size();
}
@Override
public RemoteViews getViewAt(int position) {
Reminder reminder = reminders.get(position);
boolean useMiniLayout = reminder.getDescription() == null || reminder.getDescription().isEmpty();
RemoteViews views = new RemoteViews(context.getPackageName(), useMiniLayout ? R.layout.widget_reminder_layout_small : R.layout.widget_reminder_layout);
views.setTextViewText(R.id.reminder_title, reminder.getTitle());
if (!useMiniLayout) {
views.setTextViewText(R.id.reminder_description, reminder.getDescription());
}
views.setTextViewText(R.id.reminder_time, reminder.getFormattedTime());
final Intent fillInIntent = new Intent();
final Bundle extras = new Bundle();
extras.putString(ReminderViewsService.OpenReminderId, reminder.getId());
fillInIntent.setData(Uri.parse("https://app.notesnook.com/open_reminder?id=" + reminder.getId()));
fillInIntent.putExtra(RCTNNativeModule.IntentType, "OpenReminder");
fillInIntent.putExtras(extras);
views.setOnClickFillInIntent(R.id.reminder_item_btn, fillInIntent);
return views;
}
@Override
public RemoteViews getLoadingView() {
return null;
}
@Override
public int getViewTypeCount() {
return 2;
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public boolean hasStableIds() {
return true;
}
}

View File

@@ -1,22 +1,18 @@
package com.streetwriters.notesnook;
import android.app.ActivityOptions;
import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.widget.RemoteViews;
import androidx.core.widget.RemoteViewsCompat;
import com.streetwriters.notesnook.datatypes.Reminder;
import java.util.List;
public class ReminderWidgetProvider extends AppWidgetProvider {
static String NewReminder = "com.streetwriters.notesnook.NewReminder";
static String OpenReminderId = "com.streetwriters.notesnook.OpenReminderId";
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
@@ -27,10 +23,20 @@ public class ReminderWidgetProvider extends AppWidgetProvider {
}
private static Bundle getActivityOptionsBundle() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ActivityOptions activityOptions = ActivityOptions.makeBasic();
activityOptions.setPendingIntentCreatorBackgroundActivityStartMode(
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED);
return activityOptions.toBundle();
} else
return null;
}
static void updateAppWidget(Context context, AppWidgetManager appWidgetManager, int appWidgetId, RemoteViews views) {
Intent listview_intent_template = new Intent(context, MainActivity.class);
listview_intent_template.setAction(Intent.ACTION_VIEW);
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, listview_intent_template, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE, WidgetUtils.getActivityOptionsBundle());
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, listview_intent_template, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE, getActivityOptionsBundle());
views.setPendingIntentTemplate(R.id.widget_list_view, pendingIntent);
Intent new_reminder_intent = new Intent(context, MainActivity.class);
@@ -38,31 +44,13 @@ public class ReminderWidgetProvider extends AppWidgetProvider {
new_reminder_intent.setAction(Intent.ACTION_VIEW);
new_reminder_intent.putExtra(RCTNNativeModule.IntentType, "NewReminder");
new_reminder_intent.setData(Uri.parse("https://app.notesnook.com/new_reminder"));
PendingIntent pendingIntent2 = PendingIntent.getActivity(context, appWidgetId, new_reminder_intent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE, WidgetUtils.getActivityOptionsBundle());
PendingIntent pendingIntent2 = PendingIntent.getActivity(context, appWidgetId, new_reminder_intent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE, getActivityOptionsBundle());
views.setOnClickPendingIntent(R.id.add_button, pendingIntent2);
// The rows travel with the update itself, so there is no bound service to keep in sync and
// nothing to invalidate separately: every update redraws from the current data.
List<Reminder> reminders = WidgetUtils.getWidgetReminders(context);
RemoteViewsCompat.RemoteCollectionItems.Builder items =
new RemoteViewsCompat.RemoteCollectionItems.Builder();
for (Reminder reminder : reminders) {
items.addItem(getItemId(reminder), WidgetUtils.createReminderItem(context, reminder));
}
// Two, because a reminder without a description uses the compact row layout.
items.setViewTypeCount(2);
items.setHasStableIds(true);
RemoteViewsCompat.setRemoteAdapter(context, views, appWidgetId, R.id.widget_list_view, items.build());
Intent list_remote_adapter_intent = new Intent(context, ReminderViewsService.class);
list_remote_adapter_intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
views.setRemoteAdapter(R.id.widget_list_view, list_remote_adapter_intent);
views.setEmptyView(R.id.widget_list_view, R.id.empty_view);
appWidgetManager.updateAppWidget(appWidgetId, views);
}
/**
* Ties a row to its reminder rather than to its position, so rows keep their identity when the
* list shifts around them.
*/
private static long getItemId(Reminder reminder) {
return reminder.getId() == null ? 0 : reminder.getId().hashCode();
}
}
}

View File

@@ -1,28 +0,0 @@
package com.streetwriters.notesnook;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
/**
* Redraws the widgets when the clock or timezone changes.
*
* Reminder rows are described relative to the current time ("Upcoming"/"Last", "Today"/"Tomorrow"),
* and a reminder that has passed drops off the list entirely. All of that is decided when the
* widget is drawn, so moving the clock leaves the previous drawing in place: a reminder can still
* read "Upcoming" long after its time has gone by.
*
* The app normally redraws the list when a reminder notification is delivered, but that does not
* happen if the reminder never fires, which is exactly the case when the clock jumps past it.
*
* Only TIME_SET and TIMEZONE_CHANGED are handled here: DATE_CHANGED is not exempt from the
* Android 8 limits on manifest-registered implicit broadcasts, so a receiver for it would never
* run. Crossing midnight is instead picked up by the widget's own periodic update.
*/
public class WidgetTimeChangeReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
WidgetUtils.refreshAll(context);
}
}

View File

@@ -1,299 +0,0 @@
package com.streetwriters.notesnook;
import android.app.ActivityOptions;
import android.appwidget.AppWidgetManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.text.format.DateUtils;
import android.util.Log;
import android.widget.RemoteViews;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.streetwriters.notesnook.datatypes.Note;
import com.streetwriters.notesnook.datatypes.Reminder;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* Shared helpers for the home screen widgets.
*/
public class WidgetUtils {
static final String PREFERENCES = "appPreview";
static final String REMINDERS_KEY = "remindersList";
/**
* Every row is serialized into the widget update itself, which has to fit inside a binder
* transaction, so the list cannot grow without bound. Far more than fits on screen anyway.
*/
private static final int MAX_REMINDERS = 50;
/**
* Redraws every widget that currently exists, and drops stored notes for widgets that no
* longer do.
*
* Everything else keys off what we have stored, which is fine while the app is running but
* leaves widgets showing content that no longer exists once the store is emptied underneath
* them (clearing app data) or a widget is removed while the app is stopped (onDeleted never
* arrives). Starting from the widgets the system knows about, rather than from our own data,
* is what makes this self-correcting.
*
* NoteWidget is left alone deliberately: it is a static button with no stored state, and its
* layout depends on the size it was last given.
*/
static void refreshAll(Context context) {
AppWidgetManager manager = AppWidgetManager.getInstance(context);
int[] noteWidgetIds = manager.getAppWidgetIds(
new ComponentName(context, NotePreviewWidget.class));
removeOrphanedNotes(context, noteWidgetIds);
for (int appWidgetId : noteWidgetIds) {
NotePreviewWidget.updateAppWidget(context, manager, appWidgetId);
}
for (int appWidgetId : manager.getAppWidgetIds(
new ComponentName(context, ReminderWidgetProvider.class))) {
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_reminders);
ReminderWidgetProvider.updateAppWidget(context, manager, appWidgetId, views);
}
}
/**
* Drops stored notes whose widget is gone, so the preferences file cannot grow forever.
*/
private static void removeOrphanedNotes(Context context, int[] liveWidgetIds) {
Set<String> live = new HashSet<>();
for (int appWidgetId : liveWidgetIds) live.add(String.valueOf(appWidgetId));
SharedPreferences preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE);
SharedPreferences.Editor edit = preferences.edit();
boolean changed = false;
for (String key : preferences.getAll().keySet()) {
// Leave anything that is not a widget id alone, the reminders list included.
if (parseWidgetId(key) == null || live.contains(key)) continue;
edit.remove(key);
changed = true;
}
if (changed) edit.apply();
}
/**
* The note each note widget is showing, keyed by widget id.
*
* The preferences file mixes two things: one note per widget id, and the reminders list under
* its own key. Only numeric keys are widget notes, so anything else is skipped rather than
* being treated as a note.
*/
static Map<Integer, Note> getWidgetNotes(Context context) {
SharedPreferences preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE);
Map<Integer, Note> notes = new LinkedHashMap<>();
for (Map.Entry<String, ?> entry : preferences.getAll().entrySet()) {
Integer widgetId = parseWidgetId(entry.getKey());
if (widgetId == null) continue;
if (!(entry.getValue() instanceof String)) continue;
Note note = parseNote((String) entry.getValue());
if (note == null || note.getId() == null) continue;
notes.put(widgetId, note);
}
return notes;
}
/**
* The widget id a preferences key refers to, or null if the key is not a widget id at all.
*/
private static Integer parseWidgetId(String key) {
try {
return Integer.valueOf(key);
} catch (NumberFormatException e) {
return null;
}
}
static Note parseNote(String data) {
if (data == null || data.isEmpty()) return null;
try {
return new Gson().fromJson(data, Note.class);
} catch (Exception e) {
Log.e("NotePreviewWidget", "Could not read a stored note", e);
return null;
}
}
/**
* The reminders the app last wrote out, minus any that have now dropped out of view. Reading
* and filtering happens here so the provider can push the rows straight into the widget.
*/
static List<Reminder> getWidgetReminders(Context context) {
SharedPreferences preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE);
List<Reminder> stored = null;
try {
stored = new Gson().fromJson(preferences.getString(REMINDERS_KEY, "[]"),
new TypeToken<List<Reminder>>() {}.getType());
} catch (Exception e) {
Log.e("Reminders", "Could not read the stored reminders list", e);
}
List<Reminder> active = new ArrayList<>();
if (stored == null) return active;
for (Reminder reminder : stored) {
if (!isVisibleInWidget(reminder)) continue;
if (active.size() >= MAX_REMINDERS) {
Log.w("Reminders", "Widget list truncated to " + MAX_REMINDERS + " reminders");
break;
}
active.add(reminder);
}
return active;
}
/**
* Builds a single row of the reminders list.
*/
static RemoteViews createReminderItem(Context context, Reminder reminder) {
boolean useMiniLayout = reminder.getDescription() == null || reminder.getDescription().isEmpty();
RemoteViews views = new RemoteViews(context.getPackageName(),
useMiniLayout ? R.layout.widget_reminder_layout_small : R.layout.widget_reminder_layout);
views.setTextViewText(R.id.reminder_title, reminder.getTitle());
if (!useMiniLayout) {
views.setTextViewText(R.id.reminder_description, reminder.getDescription());
}
views.setTextViewText(R.id.reminder_time, formatReminderTime(context, reminder));
Intent fillInIntent = new Intent();
fillInIntent.setData(Uri.parse("https://app.notesnook.com/open_reminder?id=" + reminder.getId()));
fillInIntent.putExtra(RCTNNativeModule.IntentType, "OpenReminder");
fillInIntent.putExtra(ReminderWidgetProvider.OpenReminderId, reminder.getId());
views.setOnClickFillInIntent(R.id.reminder_item_btn, fillInIntent);
return views;
}
/**
* Options attached to the PendingIntents our widgets hand to the launcher, opting the creator
* (us) in to background activity starts so a tap on the widget can bring up an activity.
*
* MODE_BACKGROUND_ACTIVITY_START_ALLOWED is deprecated since API 36 and Android 17 extends the
* background activity launch restrictions to IntentSender, so on API 36+ we use the narrower
* MODE_BACKGROUND_ACTIVITY_START_ALLOW_IF_VISIBLE instead. That is enough for widgets: the
* sender is the launcher, which is visible whenever the user taps the widget.
*/
static Bundle getActivityOptionsBundle() {
ActivityOptions activityOptions;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
activityOptions = ActivityOptions.makeBasic();
activityOptions.setPendingIntentCreatorBackgroundActivityStartMode(
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOW_IF_VISIBLE);
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
activityOptions = ActivityOptions.makeBasic();
activityOptions.setPendingIntentCreatorBackgroundActivityStartMode(
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED);
} else {
return null;
}
return activityOptions.toBundle();
}
/**
* How long a reminder keeps its place in the list after going off, so the user can see that it
* happened rather than watching it vanish. Must match RECENTLY_PASSED_WINDOW in
* services/notifications.ts, which decides what gets written out in the first place.
*/
private static final long RECENTLY_PASSED_WINDOW_MS = TimeUnit.HOURS.toMillis(3);
/**
* Whether a reminder should still be drawn.
*
* We re-check here rather than trusting the stored list because that list is only rewritten
* while the app runs. This is what actually retires a reminder once its grace period is up:
* every redraw re-evaluates it against the current time.
*/
static boolean isVisibleInWidget(Reminder reminder) {
if (reminder == null) return false;
if (reminder.isDisabled()) return false;
long now = System.currentTimeMillis();
if (reminder.getSnoozeUntil() > now) return true;
if (!"once".equals(reminder.getMode())) return true;
long triggerDate = reminder.getTriggerDate() > 0 ? reminder.getTriggerDate() : reminder.getDate();
return triggerDate > now - RECENTLY_PASSED_WINDOW_MS;
}
/**
* Builds the label shown under a reminder. The app sends us the absolute trigger time plus the
* parts that never change ("5:00 PM", "12-05-2026, 5:00 PM"); everything that depends on the
* current time is decided here so it stays right as the widget redraws.
*
* Falls back to the pre-formatted string for lists written by an older version of the app.
*/
static String formatReminderTime(Context context, Reminder reminder) {
long triggerDate = reminder.getTriggerDate();
String timeOfDay = reminder.getFormattedTimeOfDay();
if (triggerDate <= 0 || timeOfDay == null || timeOfDay.isEmpty()) {
return reminder.getFormattedTime();
}
if ("permanent".equals(reminder.getMode())) {
return context.getString(R.string.reminder_ongoing);
}
long now = System.currentTimeMillis();
if (reminder.getSnoozeUntil() > now) {
return context.getString(R.string.reminder_snoozed_until, timeOfDay);
}
String text;
long dayOffset = daysFromToday(triggerDate, now);
if (dayOffset == 0) {
text = context.getString(R.string.reminder_today, timeOfDay);
} else if (dayOffset == 1) {
text = context.getString(R.string.reminder_tomorrow, timeOfDay);
} else if (dayOffset == -1) {
text = context.getString(R.string.reminder_yesterday, timeOfDay);
} else {
text = reminder.getFormattedDateTime();
if (text == null || text.isEmpty()) return reminder.getFormattedTime();
}
return context.getString(
triggerDate <= now ? R.string.reminder_last : R.string.reminder_upcoming, text);
}
/**
* Calendar days between two instants. Compares midnights rather than subtracting the raw
* difference so that "tomorrow" is still tomorrow across a DST change or just before midnight.
*/
private static long daysFromToday(long time, long now) {
long target = startOfDay(time);
long today = startOfDay(now);
return Math.round((target - today) / (double) DateUtils.DAY_IN_MILLIS);
}
private static long startOfDay(long time) {
Calendar calendar = Calendar.getInstance();
calendar.setTimeInMillis(time);
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);
return calendar.getTimeInMillis();
}
}

View File

@@ -2,14 +2,13 @@ package com.streetwriters.notesnook.datatypes;
import androidx.annotation.Keep;
import java.util.concurrent.TimeUnit;
@Keep
public class Reminder extends BaseItem {
private String title;
private String description;
private String formattedTime;
private String formattedTimeOfDay; // e.g. "5:00 PM"
private String formattedDateTime; // e.g. "12-05-2026, 5:00 PM"
private long triggerDate; // absolute time this reminder next fires
private String priority; // "silent", "vibrate", "urgent"
private long date;
private String mode; // "repeat", "once", "permanent"
@@ -108,27 +107,32 @@ public class Reminder extends BaseItem {
this.formattedTime = formattedTime;
}
public String getFormattedTimeOfDay() {
return formattedTimeOfDay;
}
public String formatTime(long timeInMillis) {
long currentTime = System.currentTimeMillis();
long diff = timeInMillis - currentTime;
public void setFormattedTimeOfDay(String formattedTimeOfDay) {
this.formattedTimeOfDay = formattedTimeOfDay;
}
public String getFormattedDateTime() {
return formattedDateTime;
}
public void setFormattedDateTime(String formattedDateTime) {
this.formattedDateTime = formattedDateTime;
}
public long getTriggerDate() {
return triggerDate;
}
public void setTriggerDate(long triggerDate) {
this.triggerDate = triggerDate;
if (diff < TimeUnit.MINUTES.toMillis(1)) {
return "in " + (diff / 1000) + " seconds";
} else if (diff < TimeUnit.HOURS.toMillis(1)) {
long minutes = TimeUnit.MILLISECONDS.toMinutes(diff);
return "in " + minutes + " minute" + (minutes > 1 ? "s" : "");
} else if (diff < TimeUnit.DAYS.toMillis(1)) {
long hours = TimeUnit.MILLISECONDS.toHours(diff);
return "in " + hours + " hour" + (hours > 1 ? "s" : "");
} else if (diff < TimeUnit.DAYS.toMillis(2)) {
return "tomorrow";
} else if (diff < TimeUnit.DAYS.toMillis(7)) {
long days = TimeUnit.MILLISECONDS.toDays(diff);
return "in " + days + " day" + (days > 1 ? "s" : "");
} else if (diff < TimeUnit.DAYS.toMillis(30)) {
long weeks = TimeUnit.MILLISECONDS.toDays(diff) / 7;
return "in " + weeks + " week" + (weeks > 1 ? "s" : "");
} else if (diff < TimeUnit.DAYS.toMillis(365)) {
long months = TimeUnit.MILLISECONDS.toDays(diff) / 30;
return "in " + months + " month" + (months > 1 ? "s" : "");
} else {
long years = TimeUnit.MILLISECONDS.toDays(diff) / 365;
return "in " + years + " year" + (years > 1 ? "s" : "");
}
}
}

View File

@@ -2,6 +2,6 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/background"/>
<stroke android:width="0dp" android:color="#B1BCBE" />
<corners android:radius="@dimen/widget_background_radius"/>
<corners android:radius="10dp"/>
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
</shape>

View File

@@ -2,7 +2,8 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent">
android:background="@android:color/transparent"
android:theme="@style/ThemeOverlay.Notesnook.AppWidgetContainer">
<LinearLayout
android:layout_width="match_parent"

View File

@@ -2,7 +2,8 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent">
android:background="@android:color/transparent"
android:theme="@style/ThemeOverlay.Notesnook.AppWidgetContainer">
<LinearLayout
android:layout_width="match_parent"
@@ -24,7 +25,7 @@
android:textColor="@color/text"
android:textSize="16sp"
android:textStyle="bold"
android:text="@string/widget_note_unconfigured_title" />
android:text="Widget unconfigured" />
<TextView
android:id="@+id/widget_body"
android:layout_width="wrap_content"
@@ -33,7 +34,7 @@
android:layout_marginLeft="8dp"
android:textColor="@color/text"
android:textSize="14sp"
android:text="@string/widget_note_unconfigured_body" />
android:text="Configure this widget to show a note here." />
</LinearLayout>
</RelativeLayout>

View File

@@ -1,41 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
Shown in the widget picker only. Mirrors note_widget.xml, but with sample content, since the
real layout has nothing to show until the widget has been configured.
-->
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_centerVertical="true"
android:background="@drawable/layout_bg"
android:elevation="5dp"
android:orientation="vertical"
android:paddingHorizontal="10dp"
android:paddingVertical="10dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginLeft="8dp"
android:text="@string/widget_preview_note_title"
android:textColor="@color/text"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginLeft="8dp"
android:text="@string/widget_preview_note_body"
android:textColor="@color/text"
android:textSize="14sp" />
</LinearLayout>
</RelativeLayout>

View File

@@ -0,0 +1,8 @@
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:text="No upcoming reminders"
android:textSize="16sp"
android:textColor="@android:color/darker_gray"
android:gravity="center" />

View File

@@ -12,23 +12,22 @@
android:orientation="horizontal"
android:paddingHorizontal="12dp"
android:layout_gravity="center"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:paddingTop="12dp"
>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_marginTop="2dp"
android:textSize="15sp"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="@color/text"
android:text="Reminders"/>
android:text="Upcoming Reminders"/>
<ImageButton
android:layout_width="25dp"
android:layout_width="35dp"
android:id="@+id/add_button"
android:layout_height="25dp"
android:layout_height="35dp"
android:layout_alignParentRight="true"
android:background="@drawable/ic_newnote" />
</RelativeLayout>
@@ -58,8 +57,7 @@
android:layout_height="match_parent"
android:textAlignment="center"
android:gravity="center"
android:textColor="@color/text"
android:text="@string/widget_reminders_empty"/>
android:text="Tap on + to add reminder"/>
</LinearLayout>

View File

@@ -1,82 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
Shown in the widget picker only. Mirrors widget_reminders.xml, but with the list replaced by
sample rows, since an adapter-backed list renders empty in the picker.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/layout_bg"
android:orientation="vertical">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:paddingHorizontal="12dp"
android:paddingTop="12dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_marginTop="2dp"
android:text="Reminders"
android:textColor="@color/text"
android:textSize="16sp"
android:textStyle="bold" />
<ImageButton
android:layout_width="35dp"
android:layout_height="35dp"
android:layout_alignParentRight="true"
android:background="@drawable/ic_newnote"
android:contentDescription="@string/add_widget" />
</RelativeLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="2dp"
android:layout_marginBottom="8dp"
android:background="@color/border" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="12dp"
android:paddingBottom="12dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/widget_preview_reminder_title"
android:textColor="@color/text"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:text="@string/widget_preview_reminder_time"
android:textColor="@color/text"
android:textSize="12sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/widget_preview_reminder_title_alt"
android:textColor="@color/text"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/widget_preview_reminder_time_alt"
android:textColor="@color/text"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>

View File

@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="light_blue_50">#FFE1F5FE</color>
<color name="light_blue_200">#FF81D4FA</color>
<color name="light_blue_600">#FF039BE5</color>
<color name="light_blue_900">#FF01579B</color>
<color name="bootsplash_background">#1f1f1f</color>
<color name="background">#1D1D1D</color>
<color name="border">#2E2E2E</color>

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Android 12 onwards the launcher tells us what radius widgets should use, so ours line up
with every other widget on the home screen instead of being a fixed 10dp. -->
<dimen name="widget_background_radius">@android:dimen/system_app_widget_background_radius</dimen>
</resources>

View File

@@ -0,0 +1,6 @@
<resources>
<declare-styleable name="AppWidgetAttrs">
<attr name="appWidgetBackgroundColor" format="color" />
<attr name="appWidgetTextColor" format="color" />
</declare-styleable>
</resources>

View File

@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="light_blue_50">#FFE1F5FE</color>
<color name="light_blue_200">#FF81D4FA</color>
<color name="light_blue_600">#FF039BE5</color>
<color name="light_blue_900">#FF01579B</color>
<color name="bootsplash_background">#FFFFFF</color>
<color name="background">#DCEDEDED</color>
<color name="border">#BFBFBF</color>

View File

@@ -7,8 +7,4 @@ http://developer.android.com/guide/topics/appwidgets/index.html#CreatingLayout
-->
<dimen name="widget_margin">0dp</dimen>
<!-- Overridden in values-v31 with the platform's own widget radius, so our widgets match the
rest of the home screen. This is the fallback for older versions. -->
<dimen name="widget_background_radius">10dp</dimen>
</resources>

View File

@@ -4,28 +4,9 @@
<string name="appwidget_text">EXAMPLE</string>
<string name="add_widget">Add widget</string>
<string name="take_a_quick_note">Take a quick note.</string>
<string name="reminders">Quick overview of reminders</string>
<string name="reminders">Quick overview of upcoming reminders</string>
<string name="reminders_title">Reminders</string>
<string name="note">Note</string>
<string name="note_description">Add a note to home screen</string>
<string name="quick_note">Quick note</string>
<string name="widget_reminders_empty">Tap + to add a reminder</string>
<!-- Sample content, only ever shown in the widget picker's preview. -->
<string name="widget_preview_note_title">Meeting notes</string>
<string name="widget_preview_note_body">Discuss the roadmap and agree on timelines.</string>
<string name="widget_preview_reminder_title">Take a walk</string>
<string name="widget_preview_reminder_time">Upcoming: Today, 5:00 PM</string>
<string name="widget_preview_reminder_title_alt">Call the dentist</string>
<string name="widget_preview_reminder_time_alt">Upcoming: Tomorrow, 9:00 AM</string>
<string name="widget_note_unconfigured_title">Tap to choose a note</string>
<string name="widget_note_unconfigured_body">Pick the note you want shown here.</string>
<string name="reminder_ongoing">Ongoing</string>
<string name="reminder_snoozed_until">Snoozed until %1$s</string>
<string name="reminder_today">Today, %1$s</string>
<string name="reminder_tomorrow">Tomorrow, %1$s</string>
<string name="reminder_yesterday">Yesterday, %1$s</string>
<string name="reminder_upcoming">Upcoming: %1$s</string>
<string name="reminder_last">Last: %1$s</string>
</resources>

View File

@@ -0,0 +1,7 @@
<resources>
<style name="ThemeOverlay.Notesnook.AppWidgetContainer" parent="">
<item name="appWidgetBackgroundColor">@color/light_blue_600</item>
<item name="appWidgetTextColor">@color/light_blue_50</item>
</style>
</resources>

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
Keep this in sync with res/xml/note_widget_info.xml. It exists only to add not_keyguard: from
Android 16 QPR1 widgets are lock screen eligible by default, and this one renders the note's
title and preview text, which should not be readable on a locked device.
-->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialKeyguardLayout="@layout/note_widget"
android:initialLayout="@layout/note_widget"
android:configure="com.streetwriters.notesnook.NotePreviewConfigureActivity"
android:widgetFeatures="reconfigurable"
android:minResizeWidth="100dp"
android:minResizeHeight="50dp"
android:minWidth="400dp"
android:description="@string/note_description"
android:minHeight="50dp"
android:targetCellWidth="5"
android:targetCellHeight="1"
android:previewImage="@drawable/note_widget_preview"
android:previewLayout="@layout/note_widget_preview"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="86400000"
android:widgetCategory="home_screen|not_keyguard"/>

View File

@@ -1,15 +0,0 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_reminders"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:description="@string/reminders"
android:targetCellWidth="5"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:previewImage="@drawable/reminder_preview"
android:previewLayout="@layout/widget_reminders_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen|not_keyguard"
/>

View File

@@ -10,7 +10,6 @@
android:targetCellWidth="5"
android:targetCellHeight="1"
android:previewImage="@drawable/widget_preview"
android:previewLayout="@layout/new_note_widget"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="86400000"
android:widgetCategory="home_screen"/>

View File

@@ -12,7 +12,6 @@
android:targetCellWidth="5"
android:targetCellHeight="1"
android:previewImage="@drawable/note_widget_preview"
android:previewLayout="@layout/note_widget_preview"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="86400000"
android:widgetCategory="home_screen"/>

View File

@@ -1,15 +1,14 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_reminders"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:minWidth="400dp"
android:minHeight="100dp"
android:minResizeWidth="400dp"
android:minResizeHeight="50dp"
android:description="@string/reminders"
android:targetCellWidth="5"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:previewImage="@drawable/reminder_preview"
android:previewLayout="@layout/widget_reminders_preview"
android:updatePeriodMillis="1800000"
android:updatePeriodMillis="1024"
android:widgetCategory="home_screen"
/>

View File

@@ -13,7 +13,6 @@ buildscript {
androidXCore = "1.7.0"
androidXBrowser = "1.0.0"
ndkVersion = "27.1.12297006"
playBillingSdkVersion = "8.0.0"
}
repositories {
@@ -42,12 +41,3 @@ allprojects {
maven { url 'https://www.jitpack.io' }
}
}
// Pin the ndkVersion for all subprojects
subprojects { subproject ->
["com.android.application", "com.android.library"].each { pluginId ->
subproject.plugins.withId(pluginId) {
subproject.android.ndkVersion = rootProject.ext.ndkVersion
}
}
}

View File

@@ -1,3 +1,7 @@
- Minor bug fixes and improvements
- Inbox API is now in beta. Learn more about it at https://help.notesnook.com/inbox-api/getting-started
- Added close all tabs button in editor
- Deeplinking support for nn:// urls
- Set default image compression mode in Settings -> Behavior
- Minor bug fixes and performance improvements
Thank you for using Notesnook!

View File

@@ -23,7 +23,7 @@ import {
THEME_COMPATIBILITY_VERSION,
useThemeEngineStore
} from "@notesnook/theme";
import React, { PropsWithChildren, useEffect, useState } from "react";
import React, { PropsWithChildren, useEffect } from "react";
import { Appearance, I18nManager, Linking, StatusBar } from "react-native";
import "react-native-gesture-handler";
import { GestureHandlerRootView } from "react-native-gesture-handler";
@@ -35,7 +35,7 @@ import { Toast } from "./components/toast";
import { useAppEvents } from "./hooks/use-app-events";
import { NotePreviewConfigure } from "./screens/note-preview-configure";
import { RootNavigation } from "./navigation/navigation-stack";
import { themeTrpcClient } from "./screens/settings/theme-selector";
import { themeTrpcClient } from "./screens/settings/components/theme-selector";
import Notifications from "./services/notifications";
import SettingsService from "./services/settings";
import { TipManager } from "./services/tip-manager";
@@ -44,37 +44,27 @@ 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";
import {
initShortcutListener,
launchNewNoteTab,
registerAppShortcuts
} from "./hooks/use-shortcut-manager";
import Shortcuts from "react-native-actions-shortcuts";
import { THEME_DARK, THEME_LIGHT } from "./common/design/theme";
I18nManager.allowRTL(false);
I18nManager.forceRTL(false);
I18nManager.swapLeftAndRightInRTL(false);
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
});
});
const App = (props: { configureMode: "note-preview" }) => {
useAppEvents();
//@ts-ignore
globalThis["IS_MAIN_APP_RUNNING"] = true;
const introCompleted = useSettingStore(
(state) => state.settings.introCompleted
);
useEffect(() => {
if (introCompleted) {
registerAppShortcuts();
}
}, [introCompleted]);
useEffect(() => {
RNBootSplash.hide({ fade: true });
SettingsService.onFirstLaunch();
changeSystemBarColors();
SettingsService.setPrivacyScreen(
@@ -131,30 +121,30 @@ export const withTheme = (
]);
useEffect(() => {
setTimeout(() => {
const currentTheme = colorScheme === "dark" ? darkTheme : lightTheme;
if (!currentTheme) return;
themeTrpcClient.updateTheme
.query({
version: currentTheme.version,
compatibilityVersion: THEME_COMPATIBILITY_VERSION,
id: currentTheme.id
})
.then((theme) => {
if (theme) {
theme.colorScheme === "dark"
? useThemeStore.setState({
darkTheme: theme
})
: useThemeStore.setState({
lightTheme: theme
});
}
})
.catch(() => {
/* empty */
});
}, 1000);
// setTimeout(() => {
// const currentTheme = colorScheme === "dark" ? darkTheme : lightTheme;
// if (!currentTheme) return;
// themeTrpcClient.updateTheme
// .query({
// version: currentTheme.version,
// compatibilityVersion: THEME_COMPATIBILITY_VERSION,
// id: currentTheme.id
// })
// .then((theme) => {
// if (theme) {
// theme.colorScheme === "dark"
// ? useThemeStore.setState({
// darkTheme: theme
// })
// : useThemeStore.setState({
// lightTheme: theme
// });
// }
// })
// .catch(() => {
// /* empty */
// });
// }, 1000);
const listener = Appearance.addChangeListener(({ colorScheme }) => {
if (colorScheme && SettingsService.getProperty("useSystemTheme")) {
@@ -170,11 +160,11 @@ export const withTheme = (
}, []);
useEffect(() => {
const nextTheme = colorScheme === "dark" ? darkTheme : lightTheme;
const nextTheme = colorScheme === "dark" ? THEME_DARK : THEME_LIGHT;
if (JSON.stringify(nextTheme) !== JSON.stringify(currTheme)) {
useThemeEngineStore
.getState()
.setTheme(colorScheme === "dark" ? darkTheme : lightTheme);
.setTheme(colorScheme === "dark" ? THEME_DARK : THEME_LIGHT);
currTheme = nextTheme;
}
}, [colorScheme, darkTheme, lightTheme]);
@@ -187,41 +177,4 @@ export const withTheme = (
};
};
export const withStartupBoundry = (
Element: (props: PropsWithChildren) => JSX.Element
) => {
return function AppWithStartupBoundary(props: PropsWithChildren) {
const [ready, setReady] = useState(false);
useEffect(() => {
async function init() {
try {
const [url, shortcut] = await Promise.all([
Linking.getInitialURL(),
Shortcuts.getInitialShortcut()
]);
console.log(url, shortcut);
if (shortcut?.type === "notesnook.action.newnote") {
launchNewNoteTab();
}
useSettingStore.setState({
initialUrl: url,
pendingShortcut: shortcut ?? null
});
initShortcutListener();
} finally {
setReady(true);
}
}
init();
}, []);
if (!ready) return null;
return <Element {...props} />;
};
};
export default withStartupBoundry(withTheme(withErrorBoundry(App, "App")));
export default withTheme(withErrorBoundry(App, "App"));

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,56 @@
/*
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 FontSizes = {
XXS: 10,
XS: 12,
SM: 14,
MD: 16,
LG: 18,
XL: 20,
XXL: 32
};
export const FontFamily = {
REGULAR: "Inter-Regular", // 400
MEDIUM: "Inter-Medium", // 500
SEMI_BOLD: "Inter-SemiBold", // 600
BOLD: "Inter-Bold" // 700
};
const LineHeightMultipliers = {
"100%": 1,
"110%": 1.1,
"120%": 1.2,
"130%": 1.3,
"140%": 1.4,
"150%": 1.5
};
export type LineHeightVariants =
| "100%"
| "110%"
| "120%"
| "130%"
| "140%"
| "150%";
export const getLineHeight = (
fontSize: keyof typeof FontSizes,
type: LineHeightVariants
) => {
return FontSizes[fontSize] * LineHeightMultipliers[type];
};

View File

@@ -0,0 +1,39 @@
/*
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 Spacing = {
LEVEL_0: 4,
LEVEL_1: 8,
LEVEL_2: 12,
LEVEL_3: 16,
LEVEL_4: 20,
LEVEL_5: 24,
LEVEL_6: 28,
LEVEL_7: 32,
LEVEL_8: 40
};
export const Radius = {
XXS: 4,
XS: 8,
S: 12,
MD: 16,
LG: 20,
LG_2: 32,
XXL: 40
};

View File

@@ -0,0 +1,283 @@
/*
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 { ThemeDefinition } from "@notesnook/theme";
export const THEME_LIGHT: any = {
$schema:
"https://raw.githubusercontent.com/streetwriters/notesnook-themes/main/schemas/v1.schema.json",
name: "Notesnook Light",
id: "default-light",
version: 2.1,
license: "GPL-3.0-or-later",
homepage: "https://notesnook.com",
description: "The default light theme for Notesnook app.",
colorScheme: "light",
codeBlockCSS: ``,
compatibilityVersion: 1,
authors: [
{
name: "Streetwriters",
email: "support@streetwriters.co",
url: "https://streetwriters.co"
}
],
scopes: {
base: {
primary: {
accent: "#008836",
accentForeground: "#ffffff",
paragraph: "#666666",
background: "#ffffff",
border: "#E2E2E2",
heading: "#181818",
icon: "#181818",
separator: "#F0F0F0",
placeholder: "#858585",
hover: "#eee",
backdrop: "#0000001a",
buttonForeground: "#181818"
},
secondary: {
accent: "#0D9E46",
accentForeground: "#ffffff",
paragraph: "#858585",
background: "#F7F7F7",
border: "#EFEFEF",
heading: "#4B4B4B",
icon: "#666666",
separator: "#E8E8E8",
placeholder: "#999999",
hover: "#dbdbdb",
backdrop: "#0000001a",
buttonForeground: "#181818"
},
tertiary: {
accent: "#008836",
accentForeground: "#ffffff",
paragraph: "#777",
background: "#ECECEC",
border: "#E8E8E8",
heading: "#666",
icon: "#777",
separator: "#E8E8E8",
placeholder: "#a9a9a9",
hover: "#dbdbdb",
backdrop: "#0000001a"
},
disabled: {
accent: "#008836",
accentForeground: "#ffffff",
paragraph: "#A6A6A6",
background: "#ffffff",
border: "#E8E8E8",
heading: "#212121",
icon: "#DADADA",
separator: "#E8E8E8",
placeholder: "#a9a9a9",
hover: "#00000010",
backdrop: "#0000001a"
},
selected: {
accent: "#008836",
accentForeground: "#ffffff",
paragraph: "#181818",
background: "#00883610",
border: "#008836",
heading: "#212121",
icon: "#181818",
separator: "#E8E8E8",
placeholder: "#a9a9a9",
hover: "#eee",
backdrop: "#0000001a"
},
error: {
accent: "#f54b42",
accentForeground: "#ffffff",
paragraph: "#FF242E",
background: "#FFEDEE",
border: "#F7999D",
heading: "#f54b42",
icon: "#FB2C36",
separator: "#E8E8E8",
placeholder: "#a9a9a9",
hover: "#00000010",
backdrop: "#0000001a"
},
success: {
accent: "#4F8A10",
accentForeground: "#ffffff",
paragraph: "#4F8A10",
background: "#85ce68",
border: "#E8E8E8",
heading: "#4F8A10",
icon: "#4F8A10",
separator: "#E8E8E8",
placeholder: "#a9a9a9",
hover: "#00000010",
backdrop: "#0000001a"
}
},
statusBar: {
primary: { paragraph: "#5E5E5E", background: "#f7f7f7" }
},
navigationMenu: {
primary: {
background: "#f7f7f7",
paragraph: "#666",
icon: "#666",
hover: "#eee"
},
secondary: { background: "#EEEEEE" },
selected: {
background: "#00883610",
hover: "#eee"
}
},
contextMenu: { primary: { background: "#f7f7f7" } },
editor: { selected: { background: "#e1e1e1" } },
sheet: { selected: { paragraph: "#008836" } },
list: {
secondary: {
heading: "#A6A6A6"
}
}
}
};
export const THEME_DARK: any = {
name: "Notesnook Dark",
id: "default-dark",
version: 2.1,
license: "GPL-3.0-or-later",
homepage: "https://notesnook.com",
description: "The default dark theme for the Notesnook app",
colorScheme: "dark",
codeBlockCSS: ``,
compatibilityVersion: 1,
authors: [
{
name: "Streetwriters",
email: "support@streetwriters.co",
url: "https://streetwriters.co"
}
],
scopes: {
base: {
primary: {
accent: "#008836",
accentForeground: "#ffffff",
paragraph: "#F2F2F2",
background: "#181818",
border: "#2A2A2A",
heading: "#FFFFFF",
icon: "#FFFFFF",
separator: "#383838",
placeholder: "#404040",
hover: "#2b2b2b",
backdrop: "#35353580",
buttonForeground: "#FFFFFF"
},
secondary: {
accent: "#0D9E46",
accentForeground: "#ffffff",
paragraph: "#A1A1A1",
background: "#252525",
border: "#0A5729",
heading: "#C0BFBF",
icon: "#808080",
separator: "#383838",
placeholder: "#404040",
hover: "#ffffff10",
backdrop: "#35353580",
buttonForeground: "#BFBFBF"
},
tertiary: {
accent: "#0D9E46",
accentForeground: "#ffffff",
paragraph: "#818589",
background: "#2C2C2C",
border: "#2b2b2b",
heading: "#808080",
icon: "#808080",
separator: "#383838",
placeholder: "#404040",
hover: "#ffffff10",
backdrop: "#35353580"
},
disabled: {
accent: "#008836",
accentForeground: "#ffffff",
paragraph: "#505050",
background: "#ffffff",
border: "#383838",
heading: "#212121",
icon: "#FFFFFF",
separator: "#383838",
placeholder: "#404040",
hover: "#ffffff10",
backdrop: "#35353580"
},
selected: {
accent: "#008836",
accentForeground: "#ffffff",
paragraph: "#FBFBFB",
background: "#494949",
border: "#008836",
heading: "#e3e3e3",
icon: "#008836",
separator: "#383838",
placeholder: "#404040",
hover: "#ffffff10",
backdrop: "#35353580"
},
error: {
accent: "#f54b42",
accentForeground: "#ffffff",
paragraph: "#f54b42",
background: "#250b0a",
border: "#F7999D",
heading: "#f54b42",
icon: "#f54b42",
separator: "#383838",
placeholder: "#404040",
hover: "#ffffff10",
backdrop: "#35353580"
},
success: {
accent: "#4F8A10",
accentForeground: "#ffffff",
paragraph: "#4F8A10",
background: "#132204",
border: "#383838",
heading: "#4F8A10",
icon: "#4F8A10",
separator: "#383838",
placeholder: "#404040",
hover: "#ffffff10",
backdrop: "#35353580"
}
},
list: { selected: { background: "#202020" } },
contextMenu: { primary: { background: "#202020" } },
sheet: { selected: { paragraph: "#008836" } }
},
$schema:
"https://raw.githubusercontent.com/streetwriters/notesnook-themes/main/schemas/v1.schema.json"
};

View File

@@ -71,7 +71,7 @@ export const Announcement = () => {
alignItems: "center",
paddingHorizontal: DefaultAppStyles.GAP,
borderBottomWidth: 1,
borderBottomColor: colors.primary.border,
borderBottomColor: colors.primary.separator,
paddingBottom: DefaultAppStyles.GAP_VERTICAL_SMALL
}}
>

View File

@@ -55,6 +55,7 @@ import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view
import { strings } from "@notesnook/intl";
import { editorController } from "../../screens/editor/tiptap/utils";
import { useTabStore } from "../../screens/editor/tiptap/use-tab-store";
import { PASSWORD_PLACEHOLDER } from "../../utils/constants";
const getUser = () => {
const user = MMKV.getString("user");
@@ -96,9 +97,6 @@ const AppLocked = () => {
const lastAppState = useRef<AppStateStatus>(appState);
const biometricUnlockAwaitingUserInput = useRef(false);
const { height } = useWindowDimensions();
const keyboardType = useSettingStore(
(state) => state.settings.applockKeyboardType
);
const appLockHasPasswordSecurity = useSettingStore(
(state) => state.settings.appLockHasPasswordSecurity
);
@@ -157,8 +155,7 @@ const AppLocked = () => {
if (!appLockHasPasswordSecurity) {
await setAppLockVerificationCipher(password.current);
SettingsService.set({
appLockHasPasswordSecurity: true,
applockKeyboardType: "default"
appLockHasPasswordSecurity: true
});
DatabaseLogger.info("App lock migrated to password security");
}
@@ -167,7 +164,7 @@ const AppLocked = () => {
password.current = undefined;
} else {
ToastManager.show({
heading: strings.invalid(keyboardType),
heading: strings.invalid("password"),
type: "error",
context: "local"
});
@@ -290,9 +287,7 @@ const AppLocked = () => {
<Input
fwdRef={passwordInputRef}
secureTextEntry
keyboardType={
appLockHasPasswordSecurity ? keyboardType : "default"
}
keyboardType={"default"}
onLayout={async () => {
if (
!biometricsAuthEnabled ||
@@ -303,13 +298,7 @@ const AppLocked = () => {
}, 32);
}
}}
placeholder={
appLockHasPasswordSecurity
? keyboardType === "numeric"
? strings.enterApplockPassword()
: strings.enterApplockPin()
: strings.enterAccountPassword()
}
placeholder={PASSWORD_PLACEHOLDER}
onChangeText={(v) => (password.current = v)}
onSubmit={() => {
onSubmit();

View File

@@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { formatBytes } from "@notesnook/common";
import { Attachment, Note, VirtualizedGrouping } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import Clipboard from "@react-native-clipboard/clipboard";
import React, { RefObject, useEffect, useState } from "react";
@@ -26,6 +27,7 @@ import { TextInput, View } from "react-native";
import { ActionSheetRef } from "react-native-actions-sheet";
import { ScrollView } from "react-native-gesture-handler";
import { db } from "../../common/database";
import { Radius, Spacing } from "../../common/design/spacing";
import filesystem from "../../common/filesystem";
import downloadAttachment from "../../common/filesystem/download-attachment";
import { useAttachmentProgress } from "../../hooks/use-attachment-progress";
@@ -37,6 +39,7 @@ import {
eSendEvent,
presentSheet
} from "../../services/event-manager";
import Navigation from "../../services/navigation";
import PremiumService from "../../services/premium";
import { useAttachmentStore } from "../../stores/use-attachment-store";
import {
@@ -46,20 +49,19 @@ import {
eOnLoadNote
} from "../../utils/events";
import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { Dialog } from "../dialog";
import { presentDialog } from "../dialog/functions";
import { openNote } from "../list-items/note/wrapper";
import { DateMeta } from "../properties/date-meta";
import SheetProvider from "../sheet-provider";
import { Button } from "../ui/button";
import AppIcon from "../ui/AppIcon";
import { createFormRef, validators } from "../ui/input/form-input";
import { Notice } from "../ui/notice";
import { Pressable } from "../ui/pressable";
import LineSeparator from "../ui/seperator/line-separator";
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,
@@ -94,7 +96,7 @@ const Actions = ({
downloadAttachment(attachment.hash, context === "global");
fwdRef.current?.hide();
},
icon: "download"
icon: "download-simple"
},
{
name: strings.network.reupload(),
@@ -114,7 +116,7 @@ const Actions = ({
type: attachment.mimeType.startsWith("image") ? "image" : "file"
});
},
icon: "upload"
icon: "upload-simple"
},
{
name: strings.fileCheck(),
@@ -149,7 +151,7 @@ const Actions = ({
name: undefined
});
},
icon: "file-check"
icon: "file"
},
{
name: strings.rename(),
@@ -197,7 +199,7 @@ const Actions = ({
});
}, 500);
},
icon: "form-textbox"
icon: "pencil-simple"
},
{
name: strings.delete(),
@@ -249,7 +251,7 @@ const Actions = ({
});
}, 500);
},
icon: "delete-outline"
icon: "trash"
}
];
@@ -267,6 +269,7 @@ const Actions = ({
onMomentumScrollEnd={() => {
fwdRef?.current?.handleChildScrollEnd();
}}
bounces={false}
nestedScrollEnabled={true}
style={{
maxHeight: "100%"
@@ -277,42 +280,34 @@ const Actions = ({
<SheetProvider context={contextId} />
<View
style={{
borderBottomWidth: 1,
borderBottomColor: colors.primary.border,
marginBottom:
notes && notes.length > 0 ? 0 : DefaultAppStyles.GAP_VERTICAL
paddingHorizontal: Spacing.LEVEL_3,
paddingTop: Spacing.LEVEL_4,
gap: Spacing.LEVEL_1
}}
>
<Heading
style={{
paddingHorizontal: DefaultAppStyles.GAP
}}
size={AppFontSize.lg}
>
{filename}
</Heading>
<Heading fontSize="LG">{filename}</Heading>
<View
style={{
flexDirection: "row",
marginBottom: DefaultAppStyles.GAP_VERTICAL,
paddingHorizontal: DefaultAppStyles.GAP,
marginTop: DefaultAppStyles.GAP_VERTICAL_SMALL,
gap: 10
gap: Spacing.LEVEL_1,
alignItems: "center"
}}
>
<Paragraph size={AppFontSize.xs} color={colors.secondary.paragraph}>
<Paragraph fontSize="XS" color={colors.secondary.paragraph}>
{attachment.mimeType}
</Paragraph>
<Paragraph fontSize="XS"></Paragraph>
<Paragraph size={AppFontSize.xs} color={colors.secondary.paragraph}>
{formatBytes(attachment.size)}
</Paragraph>
<Paragraph fontSize="XS"></Paragraph>
{notes.length ? (
<Paragraph size={AppFontSize.xs} color={colors.secondary.paragraph}>
<Paragraph fontSize="XS" color={colors.secondary.paragraph}>
{strings.notes(notes.length)}
</Paragraph>
) : null}
<Paragraph fontSize="XS"></Paragraph>
<Paragraph
onPress={() => {
Clipboard.setString(attachment.hash);
@@ -322,82 +317,123 @@ const Actions = ({
context: "local"
});
}}
size={AppFontSize.xs}
fontSize="XS"
color={colors.secondary.paragraph}
>
<AppIcon
name="copy"
size={10}
color={colors.secondary.paragraph}
iconFamily="notesnook"
/>{" "}
{attachment.hash}
</Paragraph>
</View>
<LineSeparator paddingVertical={Spacing.LEVEL_2} />
<DateMeta item={attachment} />
</View>
{notes && notes.length > 0 ? (
<View
style={{
borderBottomWidth: 1,
borderBottomColor: colors.primary.border,
marginBottom: DefaultAppStyles.GAP_VERTICAL,
paddingVertical: DefaultAppStyles.GAP_VERTICAL
}}
>
<>
<Heading
style={{
paddingHorizontal: DefaultAppStyles.GAP
}}
size={AppFontSize.sm}
>
{strings.listOf()} {strings.dataTypesPlural.note()}:
</Heading>
<>
<LineSeparator
paddingHorizontal={Spacing.LEVEL_3}
paddingVertical={Spacing.LEVEL_3}
/>
<View
style={{
paddingHorizontal: Spacing.LEVEL_3,
gap: Spacing.LEVEL_2
}}
>
<>
<Heading size={AppFontSize.sm}>
{strings.notes(notes.length)}
</Heading>
{notes.map((item) => (
<Pressable
onPress={async () => {
eSendEvent(eCloseSheet, contextId);
close?.();
eSendEvent(eCloseAttachmentDialog);
Navigation.navigate("FluidPanelsView");
openNote(item, (item as any).type === "trash");
}}
style={{
paddingVertical: DefaultAppStyles.GAP_VERTICAL,
alignItems: "flex-start",
paddingHorizontal: DefaultAppStyles.GAP
}}
key={item.id}
>
<Paragraph size={AppFontSize.xs}>{item.title}</Paragraph>
</Pressable>
))}
</>
</View>
{notes.map((item) => (
<Pressable
onPress={async () => {
eSendEvent(eCloseSheet, contextId);
close?.();
eSendEvent(eCloseAttachmentDialog);
Navigation.navigate("FluidPanelsView");
openNote(item, (item as any).type === "trash");
}}
style={{
alignItems: "flex-start"
}}
key={item.id}
>
<Paragraph fontSize="XS">{item.title}</Paragraph>
</Pressable>
))}
</>
</View>
</>
) : null}
{actions.map((item) => (
<Button
key={item.name}
buttonType={{
text:
item.name === "Delete"
? colors.error.paragraph
: colors.primary.paragraph
}}
onPress={item.onPress}
title={item.name}
icon={item.icon}
loading={loading?.name === item.name}
type="plain"
fontSize={AppFontSize.sm}
style={{
borderRadius: 0,
justifyContent: "flex-start",
alignSelf: "flex-start",
width: "100%"
}}
/>
))}
<LineSeparator
paddingHorizontal={Spacing.LEVEL_3}
paddingVertical={Spacing.LEVEL_3}
/>
<View
style={{
gap: Spacing.LEVEL_2
}}
>
{actions.map((item) => (
<Pressable
key={item.name}
onPress={item.onPress}
type="plain"
style={{
borderRadius: 0,
flexDirection: "row",
width: "100%",
gap: Spacing.LEVEL_1,
justifyContent: "flex-start",
paddingHorizontal: Spacing.LEVEL_3
}}
>
<View
style={{
width: 32,
height: 32,
backgroundColor: colors.secondary.background,
borderRadius: Radius.XS,
justifyContent: "center",
alignItems: "center"
}}
>
<AppIcon
color={
item.icon === "trash"
? colors.error.accent
: colors.primary.icon
}
name={item.icon}
iconFamily="notesnook"
size={16}
/>
</View>
<Paragraph
color={
item.icon === "trash"
? colors.error.accent
: colors.primary.heading
}
fontSize="SM"
fontFamily="MEDIUM"
>
{item.name}
</Paragraph>
</Pressable>
))}
</View>
<View
style={{

View File

@@ -17,26 +17,29 @@ 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 { formatBytes } from "@notesnook/common";
import { formatBytes, getFormattedDate } from "@notesnook/common";
import { Attachment, VirtualizedGrouping } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React from "react";
import { TouchableOpacity, View } from "react-native";
import { db } from "../../common/database";
import { View } from "react-native";
import { Radius, Spacing } from "../../common/design/spacing";
import { useAttachmentProgress } from "../../hooks/use-attachment-progress";
import { useDBItem } from "../../hooks/use-db-item";
import { defaultBorderRadius, AppFontSize } from "../../utils/size";
import { IconButton } from "../ui/icon-button";
import { ProgressCircleComponent } from "../ui/svg/lazy";
import AppIcon from "../ui/AppIcon";
import { Pressable } from "../ui/pressable";
import { ProgressBarComponent } from "../ui/svg/lazy";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import Actions from "./actions";
import { strings } from "@notesnook/intl";
import { Pressable } from "../ui/pressable";
import { DefaultAppStyles } from "../../utils/styles";
import { IconButton } from "../ui/icon-button";
function getFileExtension(filename: string) {
const ext = /^.+\.([^.]+)$/.exec(filename);
return ext == null ? "" : ext[1];
function getAttachmentIcon(attachment: Attachment) {
const mimeType = attachment.mimeType || "";
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("video/")) return "video-camera";
if (mimeType.startsWith("audio/")) return "music-notes";
return "file-text";
}
export const AttachmentItem = ({
@@ -75,11 +78,15 @@ export const AttachmentItem = ({
onPress={onPress}
style={{
flexDirection: "row",
marginVertical: 5,
alignItems: "center",
justifyContent: "space-between",
padding: DefaultAppStyles.GAP,
paddingVertical: DefaultAppStyles.GAP_VERTICAL,
minHeight: 45
gap: Spacing.LEVEL_2,
borderWidth: 1,
borderColor: colors.primary.border,
borderRadius: Radius.S,
paddingHorizontal: Spacing.LEVEL_2,
paddingVertical: Spacing.LEVEL_3,
marginBottom: Spacing.LEVEL_2
}}
>
{!attachment ? null : (
@@ -89,107 +96,112 @@ export const AttachmentItem = ({
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
gap: 10
gap: Spacing.LEVEL_2
}}
>
<View
style={{
width: 40,
height: 40,
borderRadius: Radius.XS,
backgroundColor: colors.secondary.background,
justifyContent: "center",
alignItems: "center",
marginLeft: -5,
borderWidth: 1,
borderColor: colors.secondary.border,
paddingHorizontal: 2,
width: 30,
height: 30,
borderRadius: defaultBorderRadius
alignItems: "center"
}}
>
<Paragraph
adjustsFontSizeToFit
size={8}
color={colors.secondary.paragraph}
style={{
maxWidth: 30
}}
numberOfLines={1}
>
{getFileExtension(attachment.filename).toUpperCase() ||
attachment.mimeType.split("/")?.[1]?.toUpperCase()}
</Paragraph>
<AppIcon
name={getAttachmentIcon(attachment)}
iconFamily="notesnook"
size={20}
color={colors.primary.icon}
/>
</View>
<View
style={{
flexShrink: 1
flexShrink: 1,
width: "100%",
gap: Spacing.LEVEL_1
}}
>
<Paragraph
size={AppFontSize.sm}
style={{
flexWrap: "wrap"
}}
numberOfLines={1}
<Heading
fontSize="MD"
lineBreakMode="middle"
color={colors.primary.paragraph}
color={colors.primary.heading}
>
{attachment.filename}
</Paragraph>
book.pdf
</Heading>
{!hideWhenNotDownloading ? (
<Paragraph
color={colors.secondary.paragraph}
size={AppFontSize.xxs}
<View
style={{
flexDirection: "row",
gap: Spacing.LEVEL_1
}}
>
{attachment.failed ? (
<AppIcon
size={13}
name="alert-circle-outline"
color={colors.error.paragraph}
/>
) : null}
{!hideWhenNotDownloading ? (
<Paragraph
fontSize="XS"
numberOfLines={1}
color={colors.secondary.paragraph}
>
{getFormattedDate(attachment.dateModified)} |{" "}
{formatBytes(attachment.size)}
</Paragraph>
) : null}
</View>
{currentProgress ? (
<View
style={{
width: "100%",
gap: Spacing.LEVEL_0,
flexGrow: 1
}}
>
{strings.fileSize()}: {formatBytes(attachment.size)}
</Paragraph>
<ProgressBarComponent
height={6}
width={null}
animated={true}
useNativeDriver
progress={currentProgress.value}
unfilledColor={colors.secondary.background}
color={colors.primary.accent}
borderWidth={0}
/>
<Paragraph color={colors.secondary.paragraph} fontSize="XS">
{currentProgress.percent} |{" "}
{currentProgress.type === "upload"
? strings.uploading()
: strings.downloading()}
</Paragraph>
</View>
) : null}
</View>
</View>
{currentProgress ? (
<TouchableOpacity
activeOpacity={0.9}
onPress={() => {
if (encryption || !pressable) return;
db.fs().cancel(attachment.hash);
setCurrentProgress(undefined);
}}
style={{
justifyContent: "center",
marginLeft: 5,
marginTop: 5,
marginRight: -5
}}
>
<ProgressCircleComponent
size={AppFontSize.xxl}
progress={
currentProgress?.value ? currentProgress?.value / 100 : 0
}
showsText
textStyle={{
fontSize: 10
}}
color={colors.primary.accent}
formatText={(progress) => (progress * 100).toFixed(0)}
borderWidth={0}
thickness={2}
/>
</TouchableOpacity>
) : (
<>
{attachment.failed ? (
<IconButton
onPress={onPress}
name="alert-circle-outline"
color={colors.error.paragraph}
/>
) : null}
</>
)}
</>
)}
<IconButton
color={colors.secondary.icon}
name="dots-three"
iconFamily="notesnook"
size={20}
onPress={onPress}
style={{
justifyContent: "center",
height: undefined,
width: undefined,
borderRadius: 100,
alignItems: "center"
}}
/>
</Pressable>
);
};

View File

@@ -23,10 +23,9 @@ import React, { useRef, useState } from "react";
import { View } from "react-native";
import { db } from "../../common/database";
import BackupService from "../../services/backup";
import { eSendEvent, ToastManager } from "../../services/event-manager";
import { ToastManager } from "../../services/event-manager";
import Navigation from "../../services/navigation";
import { useUserStore } from "../../stores/use-user-store";
import { eOpenRecoveryKeyDialog } from "../../utils/events";
import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { Dialog } from "../dialog";
@@ -36,6 +35,8 @@ import FormInput, { createFormRef, validators } from "../ui/input/form-input";
import { Notice } from "../ui/notice";
import Paragraph from "../ui/typography/paragraph";
import { TextInput } from "react-native-gesture-handler";
import { Spacing } from "../../common/design/spacing";
import RecoveryKeySheet from "../sheets/recovery-key";
export const ChangePassword = () => {
const { colors } = useThemeColors();
@@ -92,7 +93,7 @@ export const ChangePassword = () => {
});
setLoading(false);
Navigation.goBack();
eSendEvent(eOpenRecoveryKeyDialog);
RecoveryKeySheet.present();
} catch (e) {
const message = (e as Error).message;
setLoading(false);
@@ -109,12 +110,15 @@ export const ChangePassword = () => {
<View
style={{
width: "100%",
padding: DefaultAppStyles.GAP
paddingTop: Spacing.LEVEL_4,
paddingHorizontal: Spacing.LEVEL_3,
gap: Spacing.LEVEL_2
}}
>
<Dialog context="change-password-dialog" />
<FormInput
name="oldPassword"
label={strings.oldPassword()}
formRef={formRef}
fwdRef={oldPasswordInputRef}
loading={loading}
@@ -125,7 +129,7 @@ export const ChangePassword = () => {
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.currentPassword()}
placeholder={"•••••••••"}
onSubmitEditing={() => {
passwordInputRef.current?.focus();
}}
@@ -133,6 +137,7 @@ export const ChangePassword = () => {
<FormInput
name="password"
label={strings.newPassword()}
formRef={formRef}
fwdRef={passwordInputRef}
loading={loading}
@@ -143,7 +148,28 @@ export const ChangePassword = () => {
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.newPassword()}
placeholder={"•••••••••"}
onSubmitEditing={() => {
changePassword();
}}
/>
<FormInput
name="confirmPassword"
label={strings.confirmPassword()}
formRef={formRef}
fwdRef={passwordInputRef}
loading={loading}
validators={[
validators.matchField("password", strings.confirmPasswordRequired())
]}
returnKeyLabel={strings.next()}
returnKeyType="next"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={"•••••••••"}
onSubmitEditing={() => {
changePassword();
}}
@@ -168,22 +194,39 @@ export const ChangePassword = () => {
</Paragraph>
) : null}
<Notice text={strings.changePasswordNotice()} type="alert" />
<View style={{ height: 10 }} />
<Notice text={strings.changePasswordNotice2()} type="alert" />
<Notice
text={strings.changePasswordNotice()}
type="information"
size="small"
/>
<Button
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL,
width: "100%"
width: "100%",
marginTop: Spacing.LEVEL_1
}}
loading={loading}
onPress={changePassword}
type="accent"
title={loading ? null : strings.changePasswordConfirm()}
/>
<View
style={{
flexDirection: "row",
gap: Spacing.LEVEL_1,
alignSelf: "center",
marginTop: Spacing.LEVEL_0
}}
>
<AppIcon
size={15}
name="shield-check"
iconFamily="notesnook"
color={colors.secondary.icon}
/>
<Paragraph>{strings.yourSecurityIsPriority()}</Paragraph>
</View>
</View>
);
};

View File

@@ -24,8 +24,9 @@ import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import { hideAuth } from "./common";
import { AuthParams } from "../../stores/use-navigation-store";
import { strings } from "@notesnook/intl";
import { presentDialog } from "../dialog/functions";
import { ProgressPills } from "../intro/progress-pills";
import { FontSizes } from "../../common/design/font";
import { Spacing } from "../../common/design/spacing";
export const AuthHeader = (props: { welcome?: boolean }) => {
const { colors } = useThemeColors();
const route = useRoute();
@@ -39,38 +40,42 @@ export const AuthHeader = (props: { welcome?: boolean }) => {
<View
style={{
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 12,
paddingHorizontal: Spacing.LEVEL_3,
width: "100%",
height: 50,
justifyContent: !props.welcome ? "space-between" : "flex-end"
justifyContent: "space-between",
alignItems: "center"
}}
>
{props.welcome ? null : (
{props.welcome ? (
<ProgressPills activePillIndex={1} />
) : (
<IconButton
name="arrow-left"
name="arrow-back"
iconFamily="notesnook"
onPress={() => {
hideAuth((route.params as AuthParams)?.context);
}}
size={24}
color={colors.primary.paragraph}
/>
)}
{!props.welcome ? null : (
<Button
title={strings.skipAndGoToApp()}
title="Skip"
onPress={() => {
presentDialog({
title: strings.offlineMode(),
paragraph: strings.offlineModeDesc(),
positiveText: strings.understand(),
positivePress: hideAuth as any
});
hideAuth();
}}
iconSize={24}
fontSize={FontSizes.SM}
type="plain"
height={25}
fontFamily="REGULAR"
iconPosition="right"
icon="chevron-right"
style={{
paddingHorizontal: 6
paddingRight: 0,
paddingHorizontal: 0,
paddingVertical: 0
}}
/>
)}

View File

@@ -17,15 +17,15 @@ 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, { useState } from "react";
import { SafeAreaView } from "react-native-safe-area-context";
import { useNavigationFocus } from "../../hooks/use-navigation-focus";
import { NavigationProps } from "../../services/navigation";
import { Toast } from "../toast";
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 [currentAuthMode, setCurrentAuthMode] = useState(

View File

@@ -21,8 +21,10 @@ import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import { RouteProp, useRoute } from "@react-navigation/native";
import React, { useEffect, useState } from "react";
import { TouchableOpacity, View, useWindowDimensions } from "react-native";
import { TouchableOpacity, View } from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
import { db } from "../../common/database";
import { Spacing } from "../../common/design/spacing";
import { DDS } from "../../services/device-detection";
import { eSendEvent, presentSheet } from "../../services/event-manager";
import Navigation from "../../services/navigation";
@@ -33,19 +35,20 @@ import { RouteParams } from "../../stores/use-navigation-store";
import { useUserStore } from "../../stores/use-user-store";
import { eUserLoggedIn } from "../../utils/events";
import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { sleep } from "../../utils/time";
import { Dialog } from "../dialog";
import { ProgressPills } from "../intro/progress-pills";
import { Progress } from "../sheets/progress";
import AppIcon from "../ui/AppIcon";
import { Button } from "../ui/button";
import FormInput, { validators } from "../ui/input/form-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 TwoFactorVerification from "./two-factor";
import { useLogin } from "./use-login";
import FormInput, { validators } from "../ui/input/form-input";
import AppIcon from "../ui/AppIcon";
import { PASSWORD_PLACEHOLDER } from "../../utils/constants";
const LoginSteps = {
emailAuth: 1,
@@ -68,8 +71,9 @@ export const Login = ({
passwordInputRef,
loading,
setLoading,
login,
error,
login,
mfaData,
formRef
} = useLogin(async () => {
eSendEvent(eUserLoggedIn, true);
@@ -91,8 +95,6 @@ export const Login = ({
Progress.present();
}
});
const { width, height } = useWindowDimensions();
const isTablet = width > 600;
const onContinue = () => {
login();
@@ -114,249 +116,257 @@ export const Login = ({
return (
<>
<AuthHeader />
<Dialog context="two_factor_verify" />
<View
style={{
paddingHorizontal: Spacing.LEVEL_3,
marginTop: Spacing.LEVEL_4
}}
>
<ProgressPills
count={2}
activePillIndex={step === LoginSteps.emailAuth ? 0 : 1}
/>
</View>
<KeyboardAwareScrollView
style={{
width: "100%"
}}
contentContainerStyle={{
minHeight: "90%"
minHeight: "99%"
}}
nestedScrollEnabled
enableAutomaticScroll={true}
keyboardShouldPersistTaps="handled"
>
<View
style={{
borderRadius: DDS.isTab ? 5 : 0,
backgroundColor: colors.primary.background,
zIndex: 10,
width: "100%",
height: "100%",
alignSelf: "center"
}}
>
{step === LoginSteps.emailAuth || step === LoginSteps.passwordAuth ? (
<View
style={{
justifyContent: "flex-end",
paddingHorizontal: DefaultAppStyles.GAP,
borderBottomWidth: 0.8,
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%",
minHeight: height * 0.4
borderRadius: DDS.isTab ? 5 : 0,
backgroundColor: colors.primary.background,
zIndex: 10,
width: "100%",
alignSelf: "center",
height: "100%",
paddingHorizontal: Spacing.LEVEL_3,
paddingTop: Spacing.LEVEL_6
}}
>
<View
style={{
flexDirection: "row"
}}
>
<View
style={{
width: 100,
height: 5,
backgroundColor: colors.primary.accent,
borderRadius: 2,
marginRight: 7
}}
/>
<View
style={{
width: 20,
height: 5,
backgroundColor: colors.secondary.background,
borderRadius: 2
}}
/>
</View>
<Heading
style={{
marginBottom: 25,
marginTop: DefaultAppStyles.GAP_VERTICAL
paddingBottom: Spacing.LEVEL_4
}}
extraBold
size={AppFontSize.xxl}
fontSize="XL"
>
{strings.loginToYourAccount()}
</Heading>
</View>
<View
style={{
width: DDS.isTab
? focused
? "50%"
: "49.99%"
: focused
? "100%"
: "99.9%",
backgroundColor: colors.primary.background,
alignSelf: "center",
paddingHorizontal: DDS.isTab ? 0 : DefaultAppStyles.GAP,
gap: DefaultAppStyles.GAP_VERTICAL
}}
>
<FormInput
name="email"
formRef={formRef}
fwdRef={emailInputRef}
testID="input.email"
returnKeyLabel="Next"
returnKeyType="next"
autoComplete="email"
keyboardType="email-address"
marginBottom={0}
autoCorrect={false}
autoCapitalize="none"
placeholder={strings.email()}
editable={step === LoginSteps.emailAuth && !loading}
validators={[
validators.required(strings.emailRequired()),
validators.email(strings.enterAValidEmailAddress())
]}
onSubmitEditing={() => {
if (step === LoginSteps.emailAuth) {
onContinue();
} else {
passwordInputRef.current?.focus();
}
<View
style={{
width: DDS.isTab
? focused
? "50%"
: "49.99%"
: focused
? "100%"
: "99.9%",
backgroundColor: colors.primary.background,
alignSelf: "center",
gap: Spacing.LEVEL_2
}}
/>
{step === LoginSteps.passwordAuth && (
<>
<FormInput
name="password"
formRef={formRef}
fwdRef={passwordInputRef}
testID="input.password"
returnKeyLabel={strings.done()}
returnKeyType="done"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.password()}
marginBottom={0}
editable={!loading}
validators={[validators.required(strings.passwordRequired())]}
onSubmitEditing={() => {
onContinue();
}}
/>
<Button
title={strings.forgotPassword()}
style={{
alignSelf: "flex-end",
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
paddingHorizontal: 0
}}
onPress={() => {
if (loading) return;
presentSheet({
component: (
<ForgotPassword
userEmail={formRef.current.getValue("email")}
/>
)
});
}}
textStyle={{
textDecorationLine: "underline"
}}
fontSize={AppFontSize.xs}
type="plain"
/>
</>
)}
<View>
<Button
loading={loading}
onPress={() => {
onContinue();
>
<FormInput
name="email"
formRef={formRef}
fwdRef={emailInputRef}
testID="input.email"
label={strings.email()}
returnKeyLabel="Next"
returnKeyType="next"
autoComplete="email"
keyboardType="email-address"
marginBottom={0}
autoCorrect={false}
autoCapitalize="none"
placeholder={strings.email()}
editable={step === LoginSteps.emailAuth && !loading}
validators={[
validators.required(strings.emailRequired()),
validators.email(strings.enterAValidEmailAddress())
]}
onSubmitEditing={() => {
passwordInputRef.current?.focus();
}}
style={{
width: "100%"
}}
type="accent"
title={!loading ? strings.continue() : null}
fontSize={AppFontSize.sm}
/>
{step === LoginSteps.passwordAuth && (
<FormInput
name="password"
formRef={formRef}
fwdRef={passwordInputRef}
label={strings.password()}
testID="input.password"
returnKeyLabel={strings.done()}
returnKeyType="done"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={PASSWORD_PLACEHOLDER}
marginBottom={0}
editable={!loading}
validators={[validators.required(strings.passwordRequired())]}
onSubmitEditing={() => {
onContinue();
}}
/>
<Button
title={strings.forgotPassword()}
style={{
alignSelf: "flex-end",
paddingVertical: 0,
paddingHorizontal: 0
}}
onPress={() => {
if (loading || !formRef.current.getValue("email")) return;
presentSheet({
component: (
<ForgotPassword
userEmail={formRef.current.getValue("email")}
/>
)
});
}}
fontFamily="REGULAR"
fontSize={AppFontSize.sm}
type="plain"
textStyle={{
color: colors.secondary.paragraph
}}
/>
<View
style={{
marginTop: Spacing.LEVEL_1,
gap: Spacing.LEVEL_2
}}
>
<Button
title={strings.cancelLogin()}
loading={loading}
onPress={() => {
if (loading) return;
login();
}}
style={{
alignSelf: "center",
marginTop: DefaultAppStyles.GAP_VERTICAL,
width: "100%"
}}
onPress={() => {
if (loading) return;
setStep(LoginSteps.emailAuth);
setLoading(false);
}}
type="secondaryAccented"
type="accent"
title={!loading ? strings.continue() : null}
fontSize={AppFontSize.sm}
/>
)}
{!loading ? (
<TouchableOpacity
onPress={() => {
if (loading) return;
changeMode(1);
}}
activeOpacity={0.8}
style={{
alignSelf: "center",
marginTop: DefaultAppStyles.GAP_VERTICAL,
paddingVertical: DefaultAppStyles.GAP_VERTICAL
}}
>
<Paragraph
size={AppFontSize.xs}
color={colors.secondary.paragraph}
{!loading ? (
<TouchableOpacity
onPress={() => {
if (loading) return;
changeMode(1);
}}
activeOpacity={0.8}
style={{
alignSelf: "center",
paddingVertical: 0
}}
>
{strings.dontHaveAccount()}{" "}
<Paragraph
size={AppFontSize.xs}
style={{ color: colors.primary.accent }}
>
{strings.signUp()}
<Paragraph fontSize="SM" color={colors.secondary.paragraph}>
{strings.dontHaveAccount()}{" "}
<Paragraph
fontSize="SM"
style={{ color: colors.primary.accent }}
fontFamily="SEMI_BOLD"
>
{strings.signUp()}
</Paragraph>
</Paragraph>
</Paragraph>
</TouchableOpacity>
) : null}
</TouchableOpacity>
) : null}
{error ? (
<Paragraph
numberOfLines={4}
onPress={() => {}}
color={colors.error.accent}
style={{
textAlign: "center",
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
>
<AppIcon
{error ? (
<Paragraph
numberOfLines={4}
onPress={() => {}}
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{error.message}
</Paragraph>
) : null}
style={{
textAlign: "center",
marginTop: Spacing.LEVEL_2
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{error.message}
</Paragraph>
) : null}
</View>
</View>
</View>
</View>
) : (
<View
style={{
borderRadius: DDS.isTab ? 5 : 0,
backgroundColor: colors.primary.background,
zIndex: 10,
width: "100%",
alignSelf: "center",
height: "100%",
paddingHorizontal: Spacing.LEVEL_3,
paddingTop: Spacing.LEVEL_6
}}
>
<TwoFactorVerification
onMfaLogin={async (
mfa: any,
callback: (success: boolean) => void,
onerror: (error: Error) => void
) => {
try {
const success = await db.user.authenticateMultiFactorCode(
mfa.code,
mfa.method
);
await login();
if (success) {
setLoading(false);
callback && callback(true);
}
callback && callback(false);
} catch (e) {
callback && callback(false);
if ((e as Error).message === "invalid_grant") {
setLoading(false);
setStep(LoginSteps.emailAuth);
} else {
onerror(e as Error);
}
}
}}
mfaInfo={
mfaData.current || {
primaryMethod: "email",
secondaryMethod: "sms",
token: ""
}
}
onCancel={() => {
setLoading(false);
setStep(LoginSteps.emailAuth);
}}
/>
</View>
)}
</KeyboardAwareScrollView>
</>
);

View File

@@ -45,11 +45,12 @@ import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { LoginSteps, useLogin } from "./use-login";
import { 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";
import { PASSWORD_PLACEHOLDER } from "../../utils/constants";
export const SessionExpired = () => {
const { colors } = useThemeColors();
@@ -195,24 +196,22 @@ export const SessionExpired = () => {
</Paragraph>
</View>
{step === LoginSteps.passwordAuth ? (
<FormInput
fwdRef={passwordInputRef}
formRef={formRef}
name="password"
validators={[validators.required(strings.passwordRequired())]}
returnKeyLabel={strings.done()}
returnKeyType="next"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.password()}
onSubmitEditing={() => {
login();
}}
/>
) : null}
<FormInput
fwdRef={passwordInputRef}
formRef={formRef}
name="password"
validators={[validators.required(strings.passwordRequired())]}
returnKeyLabel={strings.done()}
returnKeyType="next"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={PASSWORD_PLACEHOLDER}
onSubmitEditing={() => {
login();
}}
/>
<Button
style={{

View File

@@ -21,12 +21,7 @@ import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import { RouteProp, useRoute } from "@react-navigation/native";
import React, { useRef, useState } from "react";
import {
TextInput,
TouchableOpacity,
View,
useWindowDimensions
} from "react-native";
import { TextInput, TouchableOpacity, View } from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
import { db } from "../../common/database";
import { DDS } from "../../services/device-detection";
@@ -46,6 +41,29 @@ import { SignupContext } from "./signup-context";
import { RouteParams } from "../../stores/use-navigation-store";
import SettingsService from "../../services/settings";
import AppIcon from "../ui/AppIcon";
import { Spacing } from "../../common/design/spacing";
import { SETTING_ACCOUNT_SVG } from "../../assets/images/assets";
import { ProgressPills } from "../intro/progress-pills";
import { sleep } from "../../utils/time";
import { PASSWORD_PLACEHOLDER } from "../../utils/constants";
const getSettingAccountSvg = (dark: boolean) => {
if (dark) return SETTING_ACCOUNT_SVG;
return SETTING_ACCOUNT_SVG.replace('stroke="#1F2722"', 'stroke="#E8F4ED"')
.replace(
'fill="#008836" fill-opacity="0.09" stroke="#233C2D"',
'fill="#008836" fill-opacity="0.04" stroke="#E3F1E8"'
)
.replace(
'fill="#008836" fill-opacity="0.09" stroke="#233C2D"',
'fill="#008836" fill-opacity="0.05" stroke="#E8F0EC"'
)
.replace(
'fill="#008836" fill-opacity="0.08" stroke="#233C2D"',
'fill="#008836" fill-opacity="0.06" stroke="#D3E8DB"'
);
};
const SignupSteps = {
signup: 0,
@@ -61,7 +79,7 @@ export const Signup = ({
welcome: boolean;
}) => {
const [currentStep, setCurrentStep] = useState(SignupSteps.signup);
const { colors } = useThemeColors();
const { colors, isDark } = useThemeColors();
const formRef = useRef(
createFormRef({
email: "",
@@ -76,8 +94,6 @@ export const Signup = ({
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 signup = async () => {
@@ -89,8 +105,9 @@ export const Signup = ({
setLoading(true);
try {
setCurrentStep(SignupSteps.createAccount);
await db.user.signup(values.email.toLowerCase(), values.password);
await sleep(1000);
setCurrentStep(SignupSteps.createAccount);
const user = await db.user.getUser();
setUser(user);
setLastSynced(await db.lastSynced());
@@ -133,8 +150,9 @@ export const Signup = ({
width: "100%"
}}
contentContainerStyle={{
minHeight: "90%"
flex: 1
}}
bounces={false}
nestedScrollEnabled
keyboardShouldPersistTaps="handled"
>
@@ -145,68 +163,24 @@ export const Signup = ({
zIndex: 10,
width: "100%",
alignSelf: "center",
height: "100%"
height: "100%",
paddingHorizontal: Spacing.LEVEL_3,
paddingTop: Spacing.LEVEL_6
}}
>
<View
<Heading
style={{
justifyContent: "flex-end",
paddingHorizontal: 16,
marginBottom: DefaultAppStyles.GAP_VERTICAL,
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%",
minHeight: height * 0.25
paddingBottom: Spacing.LEVEL_4
}}
fontSize="XL"
>
<View
style={{
flexDirection: "row"
}}
>
<View
style={{
width: 100,
height: 5,
backgroundColor: colors.primary.accent,
borderRadius: 2,
marginRight: 7
}}
/>
<View
style={{
width: 20,
height: 5,
backgroundColor: colors.secondary.background,
borderRadius: 2
}}
/>
</View>
<Heading
extraBold
style={{
marginBottom: 25,
marginTop: 10
}}
size={AppFontSize.xxl}
>
{strings.createAccount()}
</Heading>
</View>
{strings.createAccount()}
</Heading>
<View
style={{
width: DDS.isTab ? "50%" : "100%",
paddingHorizontal: DDS.isTab ? 0 : 16,
backgroundColor: colors.primary.background,
flexGrow: 1,
alignSelf: "center"
gap: Spacing.LEVEL_2,
paddingBottom: Spacing.LEVEL_4
}}
>
<FormInput
@@ -214,6 +188,7 @@ export const Signup = ({
formRef={formRef}
fwdRef={emailInputRef}
loading={loading}
label={strings.email()}
testID="input.email"
returnKeyLabel="Next"
returnKeyType="next"
@@ -221,7 +196,7 @@ export const Signup = ({
keyboardType="email-address"
autoCorrect={false}
autoCapitalize="none"
placeholder={strings.email()}
placeholder="you@example.com"
blurOnSubmit={false}
validators={[
validators.required(strings.emailRequired()),
@@ -245,9 +220,11 @@ export const Signup = ({
autoCapitalize="none"
blurOnSubmit={false}
autoCorrect={false}
placeholder={strings.password()}
label={strings.password()}
placeholder={PASSWORD_PLACEHOLDER}
validators={[validators.required(strings.passwordRequired())]}
onSubmitEditing={() => {
if (formRef.current.validateField("password")) return;
confirmPasswordInputRef.current?.focus();
}}
/>
@@ -265,8 +242,8 @@ export const Signup = ({
autoCapitalize="none"
autoCorrect={false}
blurOnSubmit={false}
placeholder={strings.confirmPassword()}
marginBottom={12}
label={strings.confirmPassword()}
placeholder={PASSWORD_PLACEHOLDER}
validators={[
validators.required(strings.confirmPasswordRequired()),
validators.matchField(
@@ -278,7 +255,17 @@ export const Signup = ({
signup();
}}
/>
</View>
<View
style={{
width: DDS.isTab ? "50%" : "100%",
backgroundColor: colors.primary.background,
flexGrow: 1,
alignSelf: "center",
gap: Spacing.LEVEL_2
}}
>
<Button
title={!loading ? strings.continue() : null}
type="accent"
@@ -296,18 +283,21 @@ export const Signup = ({
}}
activeOpacity={0.8}
style={{
alignSelf: "center",
marginTop: 12,
paddingVertical: 12
alignSelf: "center"
}}
>
<Paragraph
size={AppFontSize.xs + 1}
color={colors.secondary.paragraph}
style={{
marginBottom: 25,
textAlign: "center"
}}
fontSize="SM"
color={colors.primary.paragraph}
>
{strings.alreadyHaveAccount()}{" "}
<Paragraph
size={AppFontSize.xs + 1}
fontSize="SM"
fontFamily="SEMI_BOLD"
style={{ color: colors.primary.accent }}
>
{strings.login()}
@@ -347,18 +337,16 @@ export const Signup = ({
marginBottom: 25,
textAlign: "center"
}}
size={AppFontSize.xxs}
fontSize="XS"
color={colors.secondary.paragraph}
>
{strings.signupAgreement[0]()}
<Paragraph
size={AppFontSize.xxs}
fontSize="XS"
onPress={() => {
openLinkInBrowser("https://notesnook.com/tos");
}}
style={{
textDecorationLine: "underline"
}}
fontFamily="SEMI_BOLD"
color={colors.primary.accent}
>
{" "}
@@ -366,31 +354,38 @@ export const Signup = ({
</Paragraph>{" "}
{strings.signupAgreement[2]()}
<Paragraph
size={AppFontSize.xxs}
fontSize="XS"
onPress={() => {
openLinkInBrowser("https://notesnook.com/privacy");
}}
style={{
textDecorationLine: "underline"
}}
fontFamily="SEMI_BOLD"
color={colors.primary.accent}
>
{" "}
{strings.signupAgreement[3]()}
</Paragraph>{" "}
{strings.signupAgreement[4]()}
</Paragraph>
</Paragraph>
</View>
</View>
</KeyboardAwareScrollView>
</>
) : (
<>
<View
style={{
paddingHorizontal: Spacing.LEVEL_3
}}
>
{welcome ? <ProgressPills activePillIndex={2} /> : null}
<Loading
title={"Setting up your account..."}
description="Your account is almost ready, please wait..."
title={strings.settingUpYourAccount()}
svgSrc={getSettingAccountSvg(isDark)}
description={strings.accountAlmostReady()}
style={{
height: undefined,
marginTop: Spacing.LEVEL_6
}}
/>
</>
</View>
)}
</SignupContext.Provider>
);

View File

@@ -20,19 +20,18 @@ 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 { ScrollView } from "react-native-actions-sheet";
import { TextInput, TouchableOpacity, View } from "react-native";
import { db } from "../../common/database/index";
import { Radius, Spacing } from "../../common/design/spacing";
import useTimer from "../../hooks/use-timer";
import { eSendEvent, ToastManager } from "../../services/event-manager";
import { eCloseSimpleDialog } from "../../utils/events";
import { hexToRGBA, RGB_Linear_Shade } from "../../utils/colors";
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 PinInput from "../ui/pin-input/index";
import { Pressable } from "../ui/pressable";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
@@ -73,11 +72,19 @@ const TwoFactorVerification = ({
const inputRef = useRef<TextInput>(null);
const [sending, setSending] = useState(false);
const [error, setError] = useState<Error | undefined>(undefined);
const [codeInput, setCodeInput] = useState("");
const isRecoveryCode = currentMethod.method === "recoveryCode";
const codeLength = isRecoveryCode ? 10 : 6;
const onNext = async () => {
if (!code.current || code.current.length < 6) {
setError(
new Error("Please provide a valid multi-factor authentication code.")
new Error(
currentMethod.method === "recoveryCode"
? strings.provideValidMultiFactorAuthRecoveryCode()
: strings.provideValidMultiFactorAuthCode()
)
);
return;
}
@@ -94,10 +101,7 @@ const TwoFactorVerification = ({
method: currentMethod.method,
code: code.current
},
(result) => {
if (result) {
eSendEvent(eCloseSimpleDialog, "two_factor_verify");
}
() => {
setLoading(false);
},
(e) => {
@@ -118,12 +122,14 @@ const TwoFactorVerification = ({
{
id: "sms",
title: strings.sendCodeSms(),
icon: "message-plus-outline"
icon: "chat",
iconFamily: "notesnook"
},
{
id: "email",
title: strings.sendCodeEmail(),
icon: "email-outline"
icon: "envelope-simple",
iconFamily: "notesnook"
},
{
id: "app",
@@ -133,7 +139,8 @@ const TwoFactorVerification = ({
{
id: "recoveryCode",
title: strings.recoveryCode(),
icon: "key"
icon: "lock-simple",
iconFamily: "notesnook"
}
];
@@ -155,6 +162,7 @@ const TwoFactorVerification = ({
setSending(false);
} catch (e) {
setSending(false);
console.log("error e");
setError(
new Error(`Error sending 2FA Code. Tap "Send code" to try again `)
);
@@ -168,48 +176,56 @@ const TwoFactorVerification = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentMethod.method]);
useEffect(() => {
setCodeInput("");
code.current = "";
}, [currentMethod.method]);
return (
<ScrollView
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
style={{
width: "100%",
height: "100%",
backgroundColor: colors.primary.background,
paddingTop: 60
}}
<View
onLayout={() => {
setTimeout(() => {
inputRef.current?.focus();
}, 500);
}}
style={{
alignItems: "center",
gap: Spacing.LEVEL_3
}}
>
<View
style={{
alignItems: "center",
paddingHorizontal: currentMethod.method ? 12 : 0,
gap: 12
paddingHorizontal: Spacing.LEVEL_3,
width: "100%"
}}
>
<IconButton
style={{
width: 70,
height: 70
width: 50,
height: 50,
backgroundColor: colors.primary.shade,
borderRadius: Radius.XS,
marginBottom: Spacing.LEVEL_7
}}
size={50}
name="key"
size={25}
iconFamily="notesnook"
name="shield-check"
color={colors.primary.accent}
/>
<Heading
style={{
textAlign: "center"
textAlign: "center",
marginBottom: Spacing.LEVEL_1
}}
>
{currentMethod.method ? strings["2fa"]() : strings.select2faMethod()}
{currentMethod.method === "recoveryCode"
? strings.recoveryCodeTitle()
: currentMethod.method
? strings["2fa"]()
: strings.select2faMethod()}
</Heading>
<Paragraph
style={{
width: "80%",
textAlign: "center"
}}
>
@@ -219,61 +235,35 @@ const TwoFactorVerification = ({
]?.() || strings.select2faCodeHelpText()
: strings.select2faCodeHelpText()}
</Paragraph>
</View>
{currentMethod.method === "sms" || currentMethod.method === "email" ? (
<Button
onPress={onSendCode}
type={seconds ? "plain" : "transparent"}
title={
sending
? ""
: `${
seconds
? strings.resend2faCode(`${seconds}`)
: strings.sendCode()
}`
}
loading={sending}
height={30}
/>
) : null}
{currentMethod.method ? (
<>
<Input
placeholder={
currentMethod.method === "recoveryCode"
? "xxxxx-xxxxx"
: "xxxxxx"
}
{currentMethod.method ? (
<>
<View
style={{
borderRadius: Radius.S,
width: "100%",
gap: Spacing.LEVEL_4
}}
>
<PinInput
testID={"input.totp"}
maxLength={
currentMethod.method === "recoveryCode" ? undefined : 6
}
fwdRef={inputRef}
textAlign="center"
onChangeText={(value) => {
setError(undefined);
inputRef={inputRef}
length={codeLength}
value={codeInput}
onChangeText={(value: string) => {
setCodeInput(value);
code.current = value;
}}
cursorColor={colors.selected.accent}
selectionHandleColor={colors.selected.accent}
selectionColor={colors.selected.accent}
onSubmitEditing={onNext}
height={60}
marginBottom={0}
inputStyle={{
fontSize: AppFontSize.lg,
textAlign: "center",
letterSpacing: 7,
width: 250
}}
keyboardType={
currentMethod.method === "recoveryCode" ? "default" : "numeric"
}
enablesReturnKeyAutomatically
containerStyle={{
minWidth: "50%"
keyboardType={isRecoveryCode ? "web-search" : "number-pad"}
showDashAfterDigit={isRecoveryCode ? 5 : undefined}
sanitize={(value: string) => {
if (isRecoveryCode) {
return value.replace(/[^0-9a-zA-Z]/g, "").toUpperCase();
}
return value.replace(/[^0-9]/g, "");
}}
/>
{error ? (
@@ -284,7 +274,9 @@ const TwoFactorVerification = ({
style={{
textAlign: "center",
marginVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
maxWidth: 250
maxWidth: 250,
alignSelf: "center",
marginTop: -10
}}
>
<AppIcon
@@ -296,76 +288,144 @@ const TwoFactorVerification = ({
</Paragraph>
) : null}
<Button
title={loading ? null : strings.next()}
type="accent"
width={250}
loading={loading}
onPress={onNext}
/>
<Button
title={strings.cancel()}
type="secondaryAccented"
onPress={() => {
reset();
onCancel();
<View
style={{
flexDirection: "row",
gap: Spacing.LEVEL_2
}}
width={250}
/>
<Button
title={strings["2faCodeSecondaryMethodText"][
currentMethod.method as keyof (typeof strings)["2faCodeSecondaryMethodText"]
]()}
type="plain"
onPress={onRequestSecondaryMethod}
height={30}
/>
</>
) : (
<>
{getMethods().map((item) => (
<Pressable
key={item.title}
>
<Button
title={strings.cancel()}
type="secondary"
onPress={() => {
setCurrentMethod({
method: item.id,
isPrimary: false
});
reset();
onCancel();
}}
style={{
paddingHorizontal: DefaultAppStyles.GAP,
paddingVertical: DefaultAppStyles.GAP_VERTICAL,
marginTop: 0,
flexDirection: "row",
borderRadius: 0,
alignItems: "center",
width: "100%",
justifyContent: "flex-start"
width: "48%"
}}
/>
<Button
title={loading ? null : strings.continue()}
type="accent"
loading={loading}
onPress={onNext}
style={{
width: "48%"
}}
/>
</View>
<View
style={{
gap: Spacing.LEVEL_1,
marginTop: -Spacing.LEVEL_0
}}
>
{currentMethod.method === "sms" ||
currentMethod.method === "email" ? (
<Button
onPress={onSendCode}
type={"plain"}
disabled={parseInt(`${seconds}`) !== 0}
title={
sending
? ""
: `${
seconds
? strings.resend2faCode(`${seconds}`)
: strings.resendCode()
}`
}
loading={sending}
fontSize={AppFontSize.sm}
fontFamily="REGULAR"
style={{
paddingVertical: 0,
alignSelf: "flex-start",
paddingHorizontal: 0
}}
/>
) : null}
<TouchableOpacity
onPress={onRequestSecondaryMethod}
activeOpacity={0.8}
style={{
alignSelf: "flex-start"
}}
>
<IconButton
type="secondaryAccented"
style={{
marginRight: 10
}}
size={15}
color={colors.primary.accent}
name={item.icon}
/>
<View
style={{
flexShrink: 1
}}
<Paragraph
fontFamily={isRecoveryCode ? "REGULAR" : "SEMI_BOLD"}
fontSize="SM"
color={
isRecoveryCode
? colors.primary.paragraph
: colors.primary.accent
}
>
<Paragraph size={AppFontSize.md}>{item.title}</Paragraph>
</View>
</Pressable>
))}
</>
)}
</View>
</ScrollView>
{strings["2faCodeSecondaryMethodText"][
currentMethod.method as keyof (typeof strings)["2faCodeSecondaryMethodText"]
]()}
</Paragraph>
</TouchableOpacity>
</View>
</View>
</>
) : (
<View
style={{
gap: Spacing.LEVEL_2,
width: "100%"
}}
>
{getMethods().map((item) => (
<Pressable
key={item.title}
onPress={() => {
setCurrentMethod({
method: item.id,
isPrimary: false
});
}}
style={{
padding: Spacing.LEVEL_2,
backgroundColor: colors.secondary.background,
flexDirection: "row",
borderRadius: Radius.S,
alignItems: "center",
width: "100%",
justifyContent: "flex-start",
gap: Spacing.LEVEL_1
}}
>
<IconButton
style={{
borderRadius: Radius.XS,
padding: Spacing.LEVEL_1,
width: undefined,
height: undefined,
backgroundColor: RGB_Linear_Shade(
0.04,
hexToRGBA(colors.secondary.background)
)
}}
size={17}
color={colors.primary.icon}
name={item.icon}
iconFamily={item.iconFamily as "notesnook"}
/>
<View
style={{
flexShrink: 1
}}
>
<Paragraph size={AppFontSize.sm}>{item.title}</Paragraph>
</View>
</Pressable>
))}
</View>
)}
</View>
);
};

View File

@@ -21,13 +21,11 @@ import { strings } from "@notesnook/intl";
import { useRef, useState } from "react";
import { TextInput } from "react-native";
import { db } from "../../common/database";
import { ToastManager, eSendEvent } from "../../services/event-manager";
import { ToastManager } from "../../services/event-manager";
import { clearMessage } from "../../services/message";
import PremiumService from "../../services/premium";
import SettingsService from "../../services/settings";
import { useUserStore } from "../../stores/use-user-store";
import { eCloseSimpleDialog } from "../../utils/events";
import TwoFactorVerification from "./two-factor";
import { createFormRef } from "../ui/input/form-input";
export const LoginSteps = {
@@ -46,6 +44,7 @@ export const useLogin = (
const [step, setStep] = useState(LoginSteps.emailAuth);
const emailInputRef = useRef<TextInput>(null);
const passwordInputRef = useRef<TextInput>(null);
const mfaData = useRef<any>(undefined);
const formRef = useRef(
createFormRef({
email: "",
@@ -60,69 +59,43 @@ export const useLogin = (
setLoading(true);
switch (step) {
case LoginSteps.emailAuth: {
if (formRef.current.validateField("email")) {
if (!formRef.current.validate()) {
setLoading(false);
return;
}
const mfaInfo = await db.user.authenticateEmail(
formRef.current.getValue("email")
);
if (mfaInfo) {
TwoFactorVerification.present(
async (mfa: any, callback: (success: boolean) => void, onerror: (e: Error) => void) => {
try {
const success = await db.user.authenticateMultiFactorCode(
mfa.code,
mfa.method
);
if (success) {
setStep(LoginSteps.passwordAuth);
setLoading(false);
setTimeout(() => {
passwordInputRef.current?.focus();
}, 500);
callback && callback(true);
}
callback && callback(false);
} catch (e) {
callback && callback(false);
if ((e as Error).message === "invalid_grant") {
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);
}
}
},
mfaInfo,
() => {
eSendEvent(eCloseSimpleDialog, "two_factor_verify");
setLoading(false);
setStep(LoginSteps.emailAuth);
}
);
mfaData.current = mfaInfo;
setStep(LoginSteps.mfaAuth);
setLoading(false);
} else {
finishWithError(new Error(strings.unableToSend2faCode()));
}
break;
}
case LoginSteps.passwordAuth: {
case LoginSteps.passwordAuth:
case LoginSteps.mfaAuth: {
if (!formRef.current.validate()) {
setLoading(false);
return;
}
const values = formRef.current.getValues();
await db.user.authenticatePassword(
try {
await db.user.authenticatePassword(
values.email,
values.password,
undefined,
sessionExpired
);
finishLogin();
finishLogin();
} catch(e) {
formRef.current.setError("password", (e as Error).message);
setLoading(false);
setStep(LoginSteps.passwordAuth);
}
break;
}
}
@@ -172,6 +145,7 @@ export const useLogin = (
setLoading,
error,
setError,
formRef
formRef,
mfaData
};
};

View File

@@ -36,7 +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 useGlobalSafeAreaInsets from "../../hooks/use-global-safe-area-insets";
import AppIcon from "../ui/AppIcon";
interface FloatingButtonProps {
onPress: () => void;
@@ -48,7 +48,6 @@ interface FloatingButtonProps {
position?: "left" | "right";
size?: "small" | "large";
style?: ViewStyle;
hideOnKeyboard?: boolean;
}
const FloatingButton = ({
@@ -59,28 +58,26 @@ const FloatingButton = ({
testID,
position = "right",
size = "large",
style,
hideOnKeyboard = true
style
}: FloatingButtonProps) => {
const { colors } = useThemeColors();
const deviceMode = useSettingStore((state) => state.deviceMode);
const selectionMode = useSelectionStore((state) => state.selectionMode);
const translate = useSharedValue(0);
const keyboardHeight = useSharedValue(0);
const route = useRoute();
const insets = useGlobalSafeAreaInsets();
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{
translateX: hideOnKeyboard ? translate.value : 0
},
{
translateY: hideOnKeyboard ? translate.value : 0
}
],
bottom: hideOnKeyboard ? 15 : keyboardHeight.value + 15
}));
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [
{
translateX: translate.value
},
{
translateY: translate.value
}
]
};
});
const animate = useCallback(
(toValue: number) => {
@@ -101,22 +98,12 @@ const FloatingButton = ({
editorState().keyboardState = false;
if (deviceMode !== "mobile") return;
animate(0);
keyboardHeight.value = withTiming(0);
};
const onKeyboardShow = (e: any) => {
const onKeyboardShow = () => {
editorState().keyboardState = true;
if (deviceMode !== "mobile") return;
if (hideOnKeyboard) {
animate(150);
} else {
keyboardHeight.value = withTiming(
e.endCoordinates.height - insets.bottom,
{
duration: 250
}
);
}
animate(150);
};
const sub = [
@@ -124,9 +111,9 @@ const FloatingButton = ({
Keyboard.addListener("keyboardDidHide", onKeyboardHide)
];
return () => {
sub.forEach((sub) => sub.remove());
sub.forEach((sub) => sub?.remove?.());
};
}, [deviceMode, animate, hideOnKeyboard, keyboardHeight, insets.bottom]);
}, [deviceMode, animate]);
return deviceMode !== "mobile" && !alwaysVisible ? null : (
<Animated.View
@@ -154,7 +141,7 @@ const FloatingButton = ({
borderBottomWidth: 0,
borderLeftWidth: 0,
borderRightWidth: 0,
backgroundColor: colors.primary.background
backgroundColor: colors.primary.accent
}}
onPress={onPress}
>
@@ -162,26 +149,19 @@ const FloatingButton = ({
style={{
alignItems: "center",
justifyContent: "center",
height: normalize(size === "small" ? 40 : 60),
width: normalize(size === "small" ? 40 : 60),
height: 55,
width: 55,
backgroundColor: color
? RGB_Linear_Shade(0.87, hexToRGBA(color))
: colors.primary.shade,
: colors.primary.accent,
borderRadius: size === "small" ? 15 : 20
}}
>
<Icon
name={
icon
? icon
: route.name === "Notebooks"
? "notebook-plus"
: route.name === "Trash"
? "delete"
: "plus"
}
color={color || colors.primary.accent}
size={size === "small" ? AppFontSize.xl : AppFontSize.xxxl}
<AppIcon
name={icon ? icon : route.name === "Trash" ? "trash-alt" : "plus"}
iconFamily="notesnook"
color={color || colors.primary.accentForeground}
size={24}
/>
</View>
</TouchableOpacity>

View File

@@ -1,91 +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 { useWindowDimensions, 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, "week").toDate());
const { width } = useWindowDimensions();
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
style={{
width: width * 0.8 - DefaultAppStyles.GAP * 2
}}
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

@@ -0,0 +1,200 @@
/*
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 dayjs, { locale } from "dayjs";
import React, { useMemo } from "react";
import { I18nManager, ViewStyle } from "react-native";
import RNDateTimePicker, {
DateType,
useDefaultStyles
} from "react-native-ui-datepicker";
import { FontFamily } from "../../common/design/font";
import { Radius } from "../../common/design/spacing";
import { db } from "../../common/database";
import { useSettingStore } from "../../stores/use-setting-store";
import AppIcon from "../ui/AppIcon";
import DeviceInfo from "react-native-device-info";
export type DateTimePickerMode = "date" | "time" | "datetime";
export type DateTimePickerProps = {
/**
* Which parts of the value can be picked. `date` shows only the calendar,
* `time` shows only the time wheel and `datetime` shows both.
*/
mode?: DateTimePickerMode;
date: Date;
onChange: (date: Date) => void;
minDate?: Date;
maxDate?: Date;
/**
* Force 24-hour time. Defaults to the user's time format setting.
*/
is24Hour?: boolean;
/**
* First day of the week (0 = Sunday, 1 = Monday). Defaults to the user's
* week format setting.
*/
firstDayOfWeek?: number;
style?: ViewStyle;
};
const toDate = (value: DateType) =>
value ? dayjs(value).toDate() : new Date();
/**
* Themed date/time picker built on top of `react-native-ui-datepicker`.
* Renders inline; use `DateTimePicker.present()` (see index) for the sheet.
*/
export default function DateTimePicker({
mode = "date",
date,
onChange,
minDate,
maxDate,
is24Hour,
firstDayOfWeek,
style
}: DateTimePickerProps) {
const { colors, isDark } = useThemeColors();
const defaultStyles = useDefaultStyles(isDark ? "dark" : "light");
const weekFormat = useSettingStore((state) => state.weekFormat);
const use12Hours =
(is24Hour ?? db.settings.getTimeFormat() === "24-hour") === false;
const firstDay = firstDayOfWeek ?? (weekFormat === "Mon" ? 1 : 0);
const styles = useMemo(
() =>
({
...defaultStyles,
today: {
borderColor: colors.primary.accent,
borderWidth: 1,
borderRadius: Radius.S
},
today_label: {
color: colors.primary.accent
},
selected: {
backgroundColor: colors.primary.accent,
borderRadius: Radius.S
},
selected_label: {
color: colors.static.white
},
day_label: {
color: colors.primary.paragraph,
fontFamily: FontFamily.REGULAR
},
disabled_label: {
color: colors.disabled.paragraph
},
outside_label: {
color: colors.secondary.paragraph
},
weekday_label: {
color: colors.secondary.paragraph,
fontFamily: FontFamily.MEDIUM
},
month_selector_label: {
color: colors.primary.heading,
fontFamily: FontFamily.SEMI_BOLD
},
year_selector_label: {
color: colors.primary.heading,
fontFamily: FontFamily.SEMI_BOLD
},
time_selector_label: {
color: colors.primary.heading,
fontFamily: FontFamily.SEMI_BOLD
},
month_label: {
color: colors.primary.paragraph
},
year_label: {
color: colors.primary.paragraph
},
selected_month: {
backgroundColor: colors.primary.accent,
borderRadius: Radius.S
},
selected_month_label: {
color: colors.static.white
},
selected_year: {
backgroundColor: colors.primary.accent,
borderRadius: Radius.S
},
selected_year_label: {
color: colors.static.white
},
active_year: {
backgroundColor: colors.secondary.background,
borderRadius: Radius.S
},
time_label: {
color: colors.primary.heading,
fontFamily: FontFamily.SEMI_BOLD
},
time_selected_indicator: {
backgroundColor: colors.secondary.background,
borderRadius: Radius.S
}
}) as ReturnType<typeof useDefaultStyles>,
[colors, defaultStyles]
);
return (
<RNDateTimePicker
mode="single"
date={date}
timePicker={mode !== "date"}
initialView={mode === "time" ? "time" : "day"}
hideHeader={mode === "time"}
use12Hours={use12Hours}
firstDayOfWeek={firstDay}
minDate={minDate}
maxDate={maxDate}
styles={styles}
components={{
IconPrev: (
<AppIcon
name="chevron-left"
iconFamily="notesnook"
size={20}
color={colors.primary.icon}
/>
),
IconNext: (
<AppIcon
name="chevron-right"
iconFamily="notesnook"
size={20}
color={colors.primary.icon}
/>
)
}}
onChange={(params) => {
const changed = (params as { date: DateType }).date;
onChange(toDate(changed));
}}
/>
);
}

View File

@@ -0,0 +1,224 @@
/*
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 { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useState } from "react";
import { View, ViewStyle } from "react-native";
import { Radius, Spacing } from "../../common/design/spacing";
import { presentDialog } from "../dialog/functions";
import { presentSheet } from "../../services/event-manager";
import AppIcon from "../ui/AppIcon";
import { Button } from "../ui/button";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import DateTimePicker, {
DateTimePickerMode,
DateTimePickerProps
} from "./date-time-picker";
export { default } from "./date-time-picker";
export type {
DateTimePickerMode,
DateTimePickerProps
} from "./date-time-picker";
const DEFAULT_ICONS: Record<DateTimePickerMode, string> = {
date: "calendar-dots",
time: "clock",
datetime: "calendar-dots"
};
export type PresentDateTimePickerOptions = Pick<
DateTimePickerProps,
"minDate" | "maxDate" | "is24Hour" | "firstDayOfWeek"
> & {
mode?: DateTimePickerMode;
/** Initial value the picker opens on. Defaults to now. */
date?: Date;
title?: string;
description?: string;
icon?: string;
/** Label for the confirm button. Defaults to `strings.done()`. */
confirmText?: string;
/** Dialog context to render into (dialog presentation only). */
context?: string;
onConfirm: (date: Date) => void;
onCancel?: () => void;
};
type Presentation = "dialog" | "sheet";
type DateTimePickerContentProps = PresentDateTimePickerOptions & {
presentation: Presentation;
close?: (ctx?: string) => void;
};
/**
* The shared picker body used by both the dialog and the sheet: a header,
* the themed picker, and Cancel/Confirm actions. Presentation-specific chrome
* (the dialog card vs. the sheet surface) is applied by the outer container.
*/
function DateTimePickerContent({
presentation,
mode = "date",
date,
minDate,
maxDate,
is24Hour,
firstDayOfWeek,
title,
description,
icon,
confirmText,
onConfirm,
onCancel,
close
}: DateTimePickerContentProps) {
const { colors } = useThemeColors();
const [selected, setSelected] = useState<Date>(date || new Date());
const containerStyle: ViewStyle =
presentation === "dialog"
? {
width: "90%",
maxWidth: 400,
alignSelf: "center",
backgroundColor: colors.primary.background,
borderRadius: Radius.S,
borderWidth: 0.5,
borderColor: colors.primary.border,
overflow: "hidden"
}
: {
width: "100%"
};
return (
<View style={containerStyle}>
<View
style={{
paddingHorizontal: Spacing.LEVEL_3,
paddingVertical: Spacing.LEVEL_4,
gap: Spacing.LEVEL_3
}}
>
<View
style={{
flexDirection: "row",
alignItems: "flex-start",
gap: Spacing.LEVEL_1
}}
>
<View
style={{
width: 40,
height: 40,
borderRadius: Radius.XS,
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.secondary.background
}}
>
<AppIcon
name={icon || DEFAULT_ICONS[mode]}
iconFamily="notesnook"
size={16}
color={colors.primary.icon}
/>
</View>
<View style={{ flexShrink: 1, gap: Spacing.LEVEL_1 }}>
<Heading fontSize="XL" lineHeight="100%">
{title ||
(mode === "time"
? strings.selectTimeHeading()
: strings.selectDate())}
</Heading>
{description ? (
<Paragraph fontSize="SM" color={colors.secondary.paragraph}>
{description}
</Paragraph>
) : null}
</View>
</View>
{/* <View style={{ height: 1, backgroundColor: colors.primary.border }} /> */}
<DateTimePicker
mode={mode}
date={selected}
onChange={setSelected}
minDate={minDate}
maxDate={maxDate}
is24Hour={is24Hour}
firstDayOfWeek={firstDayOfWeek}
/>
<View style={{ flexDirection: "row", gap: Spacing.LEVEL_2 }}>
<Button
title={strings.cancel()}
type="plain-outline"
style={{ flex: 1, width: "auto" }}
onPress={() => {
close?.();
onCancel?.();
}}
/>
<Button
title={confirmText || strings.done()}
type="accent"
style={{ flex: 1, width: "auto" }}
onPress={() => {
close?.();
onConfirm(selected);
}}
/>
</View>
</View>
</View>
);
}
/**
* Show the themed date/time picker in a dialog. This is the default surface
* used across the app. Resolves the picked value through `onConfirm`;
* dismissing without confirming calls `onCancel`.
*/
export function presentDateTimePicker(options: PresentDateTimePickerOptions) {
presentDialog({
context: options.context || "global",
component: (close) => (
<DateTimePickerContent {...options} presentation="dialog" close={close} />
)
});
}
/**
* Show the same date/time picker in a bottom sheet. Preferred on the
* add-reminder screen; elsewhere use {@link presentDateTimePicker}.
*/
export function presentDateTimePickerSheet(
options: PresentDateTimePickerOptions
) {
presentSheet({
context: options.context || "global",
component: (_ref, close) => (
<DateTimePickerContent {...options} presentation="sheet" close={close} />
)
});
}

View File

@@ -31,8 +31,7 @@ import ImagePreview from "../image-preview";
import MergeConflicts from "../merge-conflicts";
import SheetProvider from "../sheet-provider";
import RateAppSheet from "../sheets/rate-app";
import RecoveryKeySheet from "../sheets/recovery-key";
import Progress from "../dialogs/progress";
import Progress from "../dialogs/progress/progress";
import { useSettingStore } from "../../stores/use-setting-store";
const DialogProvider = () => {
@@ -51,7 +50,6 @@ const DialogProvider = () => {
{isAppLoading ? null : (
<>
<MergeConflicts />
<RecoveryKeySheet colors={colors} />
<VaultDialog colors={colors} />
<RateAppSheet />
<ImagePreview />

View File

@@ -20,14 +20,11 @@ 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 Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { View } from "react-native";
import { notesnook } from "../../../e2e/test.ids";
import { getColorLinearShade } from "../../utils/colors";
import { Spacing } from "../../common/design/spacing";
import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { Button, ButtonProps } from "../ui/button";
import Paragraph from "../ui/typography/paragraph";
const DialogButtons = ({
onPressPositive,
@@ -51,82 +48,41 @@ const DialogButtons = ({
return (
<View
style={[
styles.container,
{
backgroundColor: colors.secondary.background,
height: 60,
paddingHorizontal: DefaultAppStyles.GAP,
borderTopWidth: 0.7,
borderTopColor: getColorLinearShade(
colors.secondary.background,
0.05,
isDark
)
flexDirection: "row",
gap: Spacing.LEVEL_2,
paddingHorizontal: Spacing.LEVEL_3
}
]}
>
{doneText ? (
<View
style={{
flexDirection: "row",
alignItems: "center"
}}
>
<Icon
color={colors.primary.accent}
name="check-circle-outline"
size={AppFontSize.md}
/>
<Paragraph color={colors.primary.accent}>{" " + doneText}</Paragraph>
</View>
) : loading ? (
<ActivityIndicator
size={AppFontSize.lg}
color={colors.primary.accent}
/>
) : (
<View />
)}
<View
<Button
onPress={onPressNegative}
fontSize={AppFontSize.md}
testID={notesnook.ids.default.dialog.no}
type="plain-outline"
style={{
flexDirection: "row",
alignItems: "center"
flexGrow: 1,
flexShrink: 1
}}
>
title={negativeTitle}
/>
{onPressPositive ? (
<Button
onPress={onPressNegative}
fontSize={AppFontSize.sm}
testID={notesnook.ids.default.dialog.no}
type="plain"
onPress={onPressPositive}
fontSize={AppFontSize.md}
testID={notesnook.ids.default.dialog.yes}
style={{
flexGrow: 1,
flexShrink: 1
}}
loading={loading}
bold
title={negativeTitle}
type={positiveType || "accent"}
title={positiveTitle}
/>
{onPressPositive ? (
<Button
onPress={onPressPositive}
fontSize={AppFontSize.sm}
testID={notesnook.ids.default.dialog.yes}
style={{
marginLeft: 10
}}
bold
type={positiveType || "transparent"}
title={positiveTitle}
/>
) : null}
</View>
) : null}
</View>
);
};
export default DialogButtons;
const styles = StyleSheet.create({
container: {
justifyContent: "space-between",
alignItems: "center",
flexDirection: "row",
marginTop: DefaultAppStyles.GAP_VERTICAL
}
});

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