Compare commits

..

108 Commits

Author SHA1 Message Date
GitHub Actions
60edb063dd chore: bump version to 4.11.133 [skip ci] 2026-07-23 14:44:09 +00:00
0c85c36838 Skip Actions artifact upload for Android APKs.
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m11s
upload-artifact times out against git.ruv.wtf from the runner; publish the APK via the reachable synbox Gitea API in the build job instead.
2026-07-24 00:44:01 +10:00
GitHub Actions
d59646c630 chore: bump version to 4.11.132 [skip ci] 2026-07-23 14:36:29 +00:00
c1985eda15 Use a relative cinny submodule URL so CI can clone it.
Some checks failed
Build / increment-version (push) Successful in 5s
Build / create-release (push) Has been cancelled
Build / build-android (push) Has been cancelled
Absolute git.ruv.wtf URLs fail on runners that only reach the internal Gitea host.
2026-07-24 00:36:23 +10:00
GitHub Actions
2e018b0367 chore: bump version to 4.11.131 [skip ci] 2026-07-23 14:30:39 +00:00
8046474b8a Fix background push notifying for muted and mention-only rooms.
Some checks failed
Build / increment-version (push) Successful in 5s
Build / build-android (push) Failing after 4m37s
Build / create-release (push) Has been skipped
Filter Android sync wakes by push rules/unread counts, align JS system notifications, and point the cinny submodule at git.ruv.wtf.
2026-07-24 00:30:33 +10:00
GitHub Actions
9821b16968 chore: bump version to 4.11.130 [skip ci] 2026-07-22 15:32:50 +00:00
a887cd39f1 Fix Stationery theme on Android by keeping overlay hooks and CSS.
All checks were successful
Build / increment-version (push) Successful in 4s
Build / build-android (push) Successful in 4m22s
Build / create-release (push) Successful in 13s
2026-07-23 01:32:44 +10:00
GitHub Actions
ac863b3c1a chore: bump version to 4.11.129 [skip ci] 2026-07-22 15:22:58 +00:00
a889b67c2e Sync cinny: Stationery and Stationery Dark themes.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 5m1s
Build / create-release (push) Successful in 13s
2026-07-23 01:22:50 +10:00
GitHub Actions
4618d59605 chore: bump version to 4.11.128 [skip ci] 2026-07-14 17:20:29 +00:00
697f03bc51 fix: bump cinny for Inter Variable / emoji space metrics
All checks were successful
Build / increment-version (push) Successful in 8s
Build / build-android (push) Successful in 4m52s
Build / create-release (push) Successful in 14s
Point at cinny 154f4dfd (correct Inter Variable name + emoji unicode-range)
and keep the mobile overlay font stack in sync.
2026-07-15 03:20:21 +10:00
GitHub Actions
670aaf6a20 chore: bump version to 4.11.127 [skip ci] 2026-07-11 12:46:09 +00:00
c4a6b4b175 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m52s
Build / create-release (push) Successful in 14s
2026-07-11 22:46:00 +10:00
a5efb1f337 chore: update subproject status to dirty 2026-07-11 22:45:48 +10:00
GitHub Actions
8cacf555e5 chore: bump version to 4.11.126 [skip ci] 2026-07-11 12:16:49 +00:00
5a1579dd01 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m46s
Build / create-release (push) Successful in 14s
2026-07-11 22:16:40 +10:00
792c735ffe feat: improve touch handling in MobileSwipeGestureHost for better swipe detection 2026-07-11 22:16:26 +10:00
GitHub Actions
32af57d9f1 chore: bump version to 4.11.125 [skip ci] 2026-07-11 11:55:21 +00:00
197474a049 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m29s
Build / create-release (push) Successful in 15s
2026-07-11 21:55:12 +10:00
19252fdbb0 feat: enhance clipboard image handling and share functionality in Android app 2026-07-11 21:55:05 +10:00
GitHub Actions
b260f741d5 chore: bump version to 4.11.124 [skip ci] 2026-07-11 11:41:37 +00:00
7655759bbc Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m51s
Build / create-release (push) Successful in 14s
2026-07-11 21:41:27 +10:00
d30b98c987 chore: mark subproject as dirty 2026-07-11 21:41:11 +10:00
GitHub Actions
70d6f6ff42 chore: bump version to 4.11.123 [skip ci] 2026-07-11 11:24:56 +00:00
97f0a15ae2 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m50s
Build / create-release (push) Successful in 14s
2026-07-11 21:24:47 +10:00
4cf19131bf chore: mark subproject as dirty and update package-lock.json with new version and dependencies 2026-07-11 21:24:39 +10:00
GitHub Actions
83137c92a9 chore: bump version to 4.11.122 [skip ci] 2026-07-11 11:18:23 +00:00
426ddf5650 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
Some checks failed
Build / increment-version (push) Successful in 7s
Build / build-android (push) Failing after 1m0s
Build / create-release (push) Has been skipped
2026-07-11 21:18:12 +10:00
7800e7ced7 refactor: implement immersive mode for mobile keyboard layout and update related styles 2026-07-11 21:18:06 +10:00
GitHub Actions
a5afa6b493 chore: bump version to 4.11.121 [skip ci] 2026-07-11 11:03:38 +00:00
afeb45f59d Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 5m9s
Build / create-release (push) Successful in 14s
2026-07-11 21:03:28 +10:00
6f45f94f19 refactor: adjust RoomView component for improved mobile composer behavior and styling 2026-07-11 21:03:18 +10:00
GitHub Actions
fbcb291b9a chore: bump version to 4.11.120 [skip ci] 2026-07-11 10:49:12 +00:00
b7ec975fb0 refactor: update mobile gesture styles to use global styles for better maintainability
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m11s
Build / create-release (push) Successful in 14s
2026-07-11 20:48:54 +10:00
GitHub Actions
3f5d5dec98 chore: bump version to 4.11.119 [skip ci] 2026-07-11 10:36:49 +00:00
c7b6e8f5f0 refactor: enhance mobile swipe gesture handling and improve outlet behavior on compact routes
Some checks failed
Build / increment-version (push) Successful in 6s
Build / build-android (push) Failing after 1m32s
Build / create-release (push) Has been skipped
2026-07-11 20:36:10 +10:00
GitHub Actions
f709998c50 chore: bump version to 4.11.118 [skip ci] 2026-07-09 09:12:12 +00:00
ddcda65c1f Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m19s
Build / create-release (push) Successful in 14s
2026-07-09 19:12:04 +10:00
6e32bf302a chore: hide scrollbars on mobile devices for better user experience 2026-07-09 19:11:53 +10:00
GitHub Actions
ca51f93ecd chore: bump version to 4.11.117 [skip ci] 2026-07-09 08:48:12 +00:00
47c525cc5a Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m24s
Build / create-release (push) Successful in 14s
2026-07-09 18:48:04 +10:00
a282fce14a chore: update cinny submodule to latest dirty commit and refactor mobile swipe gesture handling 2026-07-09 18:47:55 +10:00
GitHub Actions
f1121839d4 chore: bump version to 4.11.116 [skip ci] 2026-07-09 08:06:48 +00:00
585eeecb1b Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m1s
Build / create-release (push) Successful in 15s
2026-07-09 18:06:32 +10:00
72dbbe29b9 chore: update cinny submodule to latest commit with mobile gesture enhancements and disable route animation on compact view 2026-07-09 18:06:06 +10:00
GitHub Actions
b6000c1db8 chore: bump version to 4.11.115 [skip ci] 2026-07-09 07:42:59 +00:00
83b4cbbd8e chore: update cinny submodule to latest commit and modify versioning for Android app
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m15s
Build / create-release (push) Successful in 13s
2026-07-09 17:42:28 +10:00
GitHub Actions
8753ed2600 chore: bump version to 4.11.114 [skip ci] 2026-07-09 07:23:56 +00:00
cced6b8ab9 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m41s
Build / create-release (push) Successful in 15s
2026-07-09 17:23:47 +10:00
82effc9680 chore: update cinny submodule to latest commit for improved navigation and routing 2026-07-09 17:23:45 +10:00
GitHub Actions
deefb40277 chore: bump version to 4.11.113 [skip ci] 2026-07-09 06:56:00 +00:00
b496c70190 chore: bump cinny submodule to 9c7f7189 (compact nav and back routing)
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m33s
Build / create-release (push) Successful in 16s
2026-07-09 16:55:52 +10:00
GitHub Actions
5a4384ea1b chore: bump version to 4.11.112 [skip ci] 2026-07-08 17:41:01 +00:00
cc974414f8 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m12s
Build / create-release (push) Successful in 14s
2026-07-09 03:40:53 +10:00
3245f41073 refactor: remove Android typography fix and associated styles from ClientNonUIFeatures 2026-07-09 03:40:51 +10:00
GitHub Actions
8b51d78db6 chore: bump version to 4.11.111 [skip ci] 2026-07-08 17:23:31 +00:00
e27ec9d7ab feat: add Android typography fix for Capacitor apps and integrate into ClientNonUIFeatures
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m30s
Build / create-release (push) Successful in 14s
2026-07-09 03:23:22 +10:00
GitHub Actions
5c18e38314 chore: bump version to 4.11.110 [skip ci] 2026-07-08 16:43:01 +00:00
64e1de954a fix: update subproject commit reference in cinny to b425827
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m12s
Build / create-release (push) Successful in 14s
2026-07-09 02:42:52 +10:00
GitHub Actions
0404cdc8ee chore: bump version to 4.11.109 [skip ci] 2026-07-08 16:07:17 +00:00
9dc177f527 fix: update version code and name in build.gradle; register PluginStoragePlugin in MainActivity; enhance text duplication handling in Editor component
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m15s
Build / create-release (push) Successful in 15s
2026-07-09 02:07:03 +10:00
655603bb59 fix: update subproject commit reference in cinny to f67f08f1 2026-07-09 02:06:56 +10:00
GitHub Actions
fb9e8bc277 chore: bump version to 4.11.108 [skip ci] 2026-05-13 01:12:28 +00:00
803a076f98 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m34s
Build / create-release (push) Successful in 14s
2026-05-13 11:12:22 +10:00
86d284b353 fix: update subproject commit reference in cinny 2026-05-13 11:12:17 +10:00
6207d18b3c fix: update version code and name in build.gradle; adjust Modal component in ShareRoomPicker 2026-05-13 11:12:09 +10:00
GitHub Actions
8d2e35d1bc chore: bump version to 4.11.107 [skip ci] 2026-05-13 00:40:41 +00:00
6845398755 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m32s
Build / create-release (push) Successful in 14s
2026-05-13 10:40:34 +10:00
be52b0fa08 fix(mobile): update version code and name, adjust padding in ShareRoomPicker component 2026-05-13 10:40:31 +10:00
GitHub Actions
298bcfd1e3 chore: bump version to 4.11.106 [skip ci] 2026-05-13 00:24:11 +00:00
fce1736312 fix(mobile): increase top padding to 60px for status bar, reduce dialog height
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m44s
Build / create-release (push) Successful in 14s
2026-05-13 10:24:02 +10:00
GitHub Actions
e11304d093 chore: bump version to 4.11.105 [skip ci] 2026-05-13 00:15:56 +00:00
60d76d5165 feat(mobile): add confirmation dialog back to share picker with backdrop
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m35s
Build / create-release (push) Successful in 14s
2026-05-13 10:15:47 +10:00
GitHub Actions
373e700a07 chore: bump version to 4.11.104 [skip ci] 2026-05-13 00:13:42 +00:00
f7b25261cc fix(mobile): make share picker fullscreen with full-width search, remove confirmation dialog
Some checks failed
Build / increment-version (push) Successful in 6s
Build / build-android (push) Has started running
Build / create-release (push) Has been cancelled
2026-05-13 10:13:35 +10:00
GitHub Actions
41e1b0db9d chore: bump version to 4.11.103 [skip ci] 2026-05-13 00:08:24 +00:00
d1022635d2 feat(mobile): improve share dialog UI with padding, full-width search, and confirmation
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m30s
Build / create-release (push) Successful in 14s
2026-05-13 10:08:16 +10:00
GitHub Actions
2e6be4b0b3 chore: bump version to 4.11.102 [skip ci] 2026-05-12 23:59:14 +00:00
0d97676edd fix(mobile): send shared content immediately instead of staging in draft
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m51s
Build / create-release (push) Successful in 14s
2026-05-13 09:59:07 +10:00
GitHub Actions
f8e8058de3 chore: bump version to 4.11.101 [skip ci] 2026-05-12 23:54:07 +00:00
a1577a3b97 fix(mobile): navigate to room after share picker selection
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m28s
Build / create-release (push) Successful in 15s
2026-05-13 09:53:57 +10:00
GitHub Actions
80f1df6588 chore: bump version to 4.11.100 [skip ci] 2026-05-12 23:44:23 +00:00
375fa74309 fix: restore live server validation while keeping input stable during typing
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m48s
Build / create-release (push) Successful in 14s
2026-05-13 09:44:14 +10:00
GitHub Actions
0862094e00 chore: bump version to 4.11.99 [skip ci] 2026-05-12 23:41:58 +00:00
45d66bbe28 fix(mobile): prevent server input focus loss and text modification while typing
Some checks failed
Build / increment-version (push) Successful in 6s
Build / create-release (push) Has been cancelled
Build / build-android (push) Has been cancelled
2026-05-13 09:41:49 +10:00
GitHub Actions
919bc310a7 chore: bump version to 4.11.98 [skip ci] 2026-05-12 23:32:30 +00:00
badcfb160c fix(mobile): handle Android keyboard autocorrect text duplication in overlay
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m15s
Build / create-release (push) Successful in 13s
2026-05-13 09:32:22 +10:00
GitHub Actions
1b35f71bb8 chore: bump version to 4.11.97 [skip ci] 2026-05-12 22:59:45 +00:00
96349bf200 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m26s
Build / create-release (push) Successful in 14s
2026-05-13 08:59:38 +10:00
472d30940d chore: remove extra whitespace in README.md 2026-05-13 08:59:36 +10:00
GitHub Actions
ae9d2bfeb6 chore: bump version to 4.11.96 [skip ci] 2026-05-12 22:52:08 +00:00
61b82d1f40 fix: use consistent signing cert for all builds to prevent update conflicts
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m53s
Build / create-release (push) Successful in 14s
2026-05-13 08:51:57 +10:00
GitHub Actions
b008011d5f chore: bump version to 4.11.95 [skip ci] 2026-05-12 22:42:45 +00:00
938f000f65 chore: remove extra newline in README.md
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m44s
Build / create-release (push) Successful in 14s
2026-05-13 08:42:35 +10:00
GitHub Actions
7fdf369883 chore: bump version to 4.11.94 [skip ci] 2026-05-12 22:28:54 +00:00
c9883a1279 chore: remove extra newline in SIGNING_SETUP.md
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m49s
Build / create-release (push) Successful in 23s
2026-05-13 08:28:45 +10:00
GitHub Actions
5f09b0dd71 chore: bump version to 4.11.93 [skip ci] 2026-05-12 22:20:57 +00:00
0e436f076e Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m7s
Build / create-release (push) Successful in 14s
2026-05-13 08:20:49 +10:00
2281b31894 feat: add signing configuration for debug and release builds based on keystore properties 2026-05-13 08:20:47 +10:00
GitHub Actions
0eda439897 chore: bump version to 4.11.92 [skip ci] 2026-05-12 22:03:52 +00:00
a1bd03b277 chore: update subproject commit reference to 7ef9939
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m42s
Build / create-release (push) Successful in 14s
2026-05-13 08:03:43 +10:00
GitHub Actions
1c2904898f chore: bump version to 4.11.91 [skip ci] 2026-04-28 05:46:30 +00:00
0fe41a55c1 feat: add custom notification sound for message alerts in MatrixSyncService
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m56s
Build / create-release (push) Successful in 14s
2026-04-28 15:46:20 +10:00
af2f7dbcbc feat: enhance MatrixSyncService with user profile handling and image notifications 2026-04-26 18:54:58 +10:00
GitHub Actions
0a4a9aa636 chore: bump version to 4.11.90 [skip ci] 2026-04-26 04:32:30 +00:00
746d0aa384 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m42s
Build / create-release (push) Successful in 14s
2026-04-26 14:32:22 +10:00
c263b681d4 feat: add ShareRoomPicker component for selecting rooms to share Android content
- Implemented ShareRoomPicker component to allow users to pick a room for sharing content received from Android intents.
- Integrated search functionality to filter rooms and directs.
- Added virtualized list for efficient rendering of room options.
- Created utility functions for handling Android share payloads, including fetching and clearing pending shares.

Co-authored-by: Copilot <copilot@github.com>
2026-04-26 14:32:19 +10:00
58 changed files with 8615 additions and 120 deletions

View File

@@ -102,29 +102,7 @@ jobs:
- name: Build Android APK - name: Build Android APK
run: npm run android:apk:linux run: npm run android:apk:linux
- name: Upload APK
uses: actions/upload-artifact@v3
with:
name: Paarrot-Android.apk
path: android/app/build/outputs/apk/debug/Paarrot-*.apk
create-release:
runs-on: ubuntu-latest
needs: [increment-version, build-android]
if: always() && needs.build-android.result == 'success'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.ref_name }}
- name: Pull latest (after version bump)
if: github.ref == 'refs/heads/master'
run: git pull origin master
- name: Get version - name: Get version
id: version id: version
run: | run: |
@@ -135,7 +113,14 @@ jobs:
fi fi
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION" echo "Version: $VERSION"
- name: Prepare release files
run: |
mkdir -p release-files
find android/app/build/outputs/apk/debug -type f -name "Paarrot-*.apk" -exec cp {} release-files/ \;
ls -la release-files/
test -n "$(ls -A release-files/*.apk 2>/dev/null)"
- name: Check if tag exists - name: Check if tag exists
id: tag_check id: tag_check
run: | run: |
@@ -147,7 +132,7 @@ jobs:
echo "Tag v$VERSION does not exist" echo "Tag v$VERSION does not exist"
echo "exists=false" >> $GITHUB_OUTPUT echo "exists=false" >> $GITHUB_OUTPUT
fi fi
- name: Create and push tag - name: Create and push tag
if: steps.tag_check.outputs.exists == 'false' if: steps.tag_check.outputs.exists == 'false'
run: | run: |
@@ -155,18 +140,9 @@ jobs:
git config user.email "actions@github.com" git config user.email "actions@github.com"
git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}" git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}"
git push origin "v${{ steps.version.outputs.VERSION }}" git push origin "v${{ steps.version.outputs.VERSION }}"
- name: Download all artifacts # Upload via the internal Gitea API. actions/upload-artifact times out against
uses: actions/download-artifact@v3 # git.ruv.wtf:443 from this runner; synbox:8418 is reachable on the LAN.
with:
path: artifacts
- name: Prepare release files
run: |
mkdir -p release-files
find artifacts -type f -name "*.apk" -exec cp {} release-files/ \;
ls -la release-files/
- name: Create or Update Gitea Release - name: Create or Update Gitea Release
env: env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -175,10 +151,10 @@ jobs:
TAG_NAME="v${VERSION}" TAG_NAME="v${VERSION}"
API_BASE="http://synbox.ruv.wtf:8418/api/v1" API_BASE="http://synbox.ruv.wtf:8418/api/v1"
REPO="litruv/cinny-mobile" REPO="litruv/cinny-mobile"
RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \ RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty') "${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
if [ -z "$RELEASE_ID" ]; then if [ -z "$RELEASE_ID" ]; then
echo "Creating new release for ${TAG_NAME}..." echo "Creating new release for ${TAG_NAME}..."
RELEASE_ID=$(curl -s -X POST \ RELEASE_ID=$(curl -s -X POST \
@@ -198,7 +174,7 @@ jobs:
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}" "${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
done done
fi fi
echo "Uploading assets..." echo "Uploading assets..."
for FILE in release-files/*; do for FILE in release-files/*; do
FILENAME="Paarrot-${VERSION}.apk" FILENAME="Paarrot-${VERSION}.apk"
@@ -210,9 +186,9 @@ jobs:
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}" "${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
echo "" echo ""
done done
echo "Gitea release complete!" echo "Gitea release complete!"
- name: Create GitHub Release - name: Create GitHub Release
env: env:
GH_TOKEN: ${{ secrets.GHTOKEN }} GH_TOKEN: ${{ secrets.GHTOKEN }}
@@ -220,10 +196,10 @@ jobs:
VERSION="${{ steps.version.outputs.VERSION }}" VERSION="${{ steps.version.outputs.VERSION }}"
TAG_NAME="v${VERSION}" TAG_NAME="v${VERSION}"
GITHUB_REPO="Paarrot/Paarrot-Mobile" GITHUB_REPO="Paarrot/Paarrot-Mobile"
RELEASE_ID=$(curl -s -H "Authorization: token ${GH_TOKEN}" \ RELEASE_ID=$(curl -s -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty') "https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
if [ -z "$RELEASE_ID" ]; then if [ -z "$RELEASE_ID" ]; then
echo "Creating new GitHub release for ${TAG_NAME}..." echo "Creating new GitHub release for ${TAG_NAME}..."
RELEASE_ID=$(curl -s -X POST \ RELEASE_ID=$(curl -s -X POST \
@@ -235,11 +211,11 @@ jobs:
else else
echo "GitHub release exists with ID: ${RELEASE_ID}" echo "GitHub release exists with ID: ${RELEASE_ID}"
fi fi
echo "Uploading assets to GitHub..." echo "Uploading assets to GitHub..."
UPLOAD_URL=$(curl -s -H "Authorization: token ${GH_TOKEN}" \ UPLOAD_URL=$(curl -s -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/${RELEASE_ID}" | jq -r '.upload_url' | sed 's/{?name,label}//') "https://api.github.com/repos/${GITHUB_REPO}/releases/${RELEASE_ID}" | jq -r '.upload_url' | sed 's/{?name,label}//')
for FILE in release-files/*; do for FILE in release-files/*; do
FILENAME="Paarrot-${VERSION}.apk" FILENAME="Paarrot-${VERSION}.apk"
echo "Uploading ${FILENAME} to GitHub..." echo "Uploading ${FILENAME} to GitHub..."
@@ -250,5 +226,5 @@ jobs:
"${UPLOAD_URL}?name=${FILENAME}" "${UPLOAD_URL}?name=${FILENAME}"
echo "" echo ""
done done
echo "GitHub release complete!" echo "GitHub release complete!"

2
.gitmodules vendored
View File

@@ -1,3 +1,3 @@
[submodule "cinny"] [submodule "cinny"]
path = cinny path = cinny
url = http://synbox.ruv.wtf:8418/litruv/cinny.git url = ../cinny.git

4
android/.gitignore vendored
View File

@@ -52,10 +52,8 @@ captures/
# Comment next line if keeping position of elements in Navigation Editor is relevant for you # Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml .idea/navEditor.xml
# Keystore files - NEVER commit these! # Keystore files
*.jks *.jks
*.keystore
keystore.properties
# External native build folder generated in Android Studio 2.2 and later # External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild .externalNativeBuild

View File

@@ -1,5 +1,5 @@
# Android App Signing Setup # Android App Signing Setup
## Why This Matters ## Why This Matters
Android requires apps to be signed with the same key for updates to work. Without proper signing, users cannot update your app - they must uninstall and reinstall, losing all data. Android requires apps to be signed with the same key for updates to work. Without proper signing, users cannot update your app - they must uninstall and reinstall, losing all data.

View File

@@ -27,16 +27,24 @@ android {
applicationId "com.paarrot.app" applicationId "com.paarrot.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 1783582912
versionName "1.0" versionName "2026-07-09.074152.772"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
} }
if (keystorePropertiesFile.exists()) {
signingConfig signingConfigs.release
}
} }
buildTypes { buildTypes {
debug {
if (keystorePropertiesFile.exists()) {
signingConfig signingConfigs.release
}
}
release { release {
minifyEnabled false minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'

View File

@@ -22,6 +22,24 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity> </activity>
<provider <provider

View File

@@ -0,0 +1,32 @@
package com.paarrot.app
import android.net.Uri
import android.view.View
import androidx.core.view.ContentInfoCompat
import androidx.core.view.OnReceiveContentListener
/**
* Receives rich content from the IME (Gboard GIF), clipboard paste, and drag-drop,
* then forwards file URIs through [ShareIntentStore] so JS can attach them in-room.
*/
class ImageKeyboardContentReceiver : OnReceiveContentListener {
override fun onReceiveContent(view: View, payload: ContentInfoCompat): ContentInfoCompat? {
val split = payload.partition { item -> item.uri != null }
val uriContent = split.first
val remaining = split.second
if (uriContent != null) {
val uris = mutableListOf<Uri>()
val clip = uriContent.clip
for (i in 0 until clip.itemCount) {
clip.getItemAt(i).uri?.let { uris.add(it) }
}
if (uris.isNotEmpty()) {
ShareIntentStore.handleContentUris(view.context, uris)
}
}
return remaining
}
}

View File

@@ -0,0 +1,38 @@
package com.paarrot.app
import android.content.Context
import android.util.AttributeSet
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat
import com.getcapacitor.CapacitorWebView
/**
* Capacitor WebView that advertises image/video MIME types to the IME
* (Gboard GIF/sticker keyboard) and routes commit-content through
* [androidx.core.view.ViewCompat.setOnReceiveContentListener].
*/
class ImageKeyboardWebView(
context: Context,
attrs: AttributeSet?,
) : CapacitorWebView(context, attrs) {
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
val inputConnection = super.onCreateInputConnection(outAttrs) ?: return null
EditorInfoCompat.setContentMimeTypes(outAttrs, MIME_TYPES)
return InputConnectionCompat.createWrapper(this, inputConnection, outAttrs)
}
companion object {
@JvmField
val MIME_TYPES: Array<String> = arrayOf(
"image/*",
"image/png",
"image/gif",
"image/jpeg",
"image/webp",
"video/*",
)
}
}

View File

@@ -1,12 +1,29 @@
package com.paarrot.app; package com.paarrot.app;
import android.os.Bundle; import android.os.Bundle;
import android.webkit.WebView;
import androidx.core.view.ViewCompat;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity { public class MainActivity extends BridgeActivity {
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
registerPlugin(SyncServicePlugin.class); registerPlugin(SyncServicePlugin.class);
registerPlugin(ShareHandlerPlugin.class);
registerPlugin(PluginStoragePlugin.class);
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setupImageKeyboardSupport();
}
/** Advertise image MIME types and receive Gboard / paste / drag-drop media. */
private void setupImageKeyboardSupport() {
if (bridge == null) return;
WebView webView = bridge.getWebView();
if (webView == null) return;
ViewCompat.setOnReceiveContentListener(
webView,
ImageKeyboardWebView.MIME_TYPES,
new ImageKeyboardContentReceiver()
);
} }
} }

View File

@@ -5,10 +5,14 @@ import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.media.AudioAttributes
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -34,6 +38,14 @@ class MatrixSyncService : Service() {
/** Event IDs already shown as notifications this session — prevents duplicates on restart. */ /** Event IDs already shown as notifications this session — prevents duplicates on restart. */
private val shownEventIds = HashSet<String>(64) private val shownEventIds = HashSet<String>(64)
/** In-memory cache of MXID → display name to avoid redundant API calls per service run. */
private val displayNameCache = HashMap<String, String>(16)
/** In-memory cache of MXID → avatar bitmap (null = no avatar or fetch failed). */
private val avatarCache = HashMap<String, Bitmap?>(16)
private data class UserProfile(val displayName: String, val avatar: Bitmap?)
override fun onBind(intent: Intent?) = null override fun onBind(intent: Intent?) = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -139,15 +151,43 @@ class MatrixSyncService : Service() {
} }
} }
private enum class RoomNotifyMode {
MUTE,
ALL_MESSAGES,
MENTIONS_AND_KEYWORDS,
}
private data class NotifyContext(
val myUserId: String,
val myDisplayName: String?,
val myLocalpart: String?,
val keywords: List<String>,
val directRoomIds: Set<String>,
val pushRules: JSONObject?,
)
private fun processRoomEvents(sync: JSONObject, myUserId: String) { private fun processRoomEvents(sync: JSONObject, myUserId: String) {
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
val homeserver = prefs.getString(EXTRA_HOMESERVER, null)?.trimEnd('/') ?: return
val token = prefs.getString(EXTRA_TOKEN, null) ?: return
val joinedRooms = sync.optJSONObject("rooms")?.optJSONObject("join") ?: return val joinedRooms = sync.optJSONObject("rooms")?.optJSONObject("join") ?: return
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
ensureMessageChannel(nm) ensureMessageChannel(nm)
val notifyCtx = loadNotifyContext(homeserver, token, myUserId)
val roomIds = joinedRooms.keys().asSequence().toList() val roomIds = joinedRooms.keys().asSequence().toList()
for (roomId in roomIds) { for (roomId in roomIds) {
val timeline = joinedRooms.optJSONObject(roomId) val roomData = joinedRooms.optJSONObject(roomId) ?: continue
?.optJSONObject("timeline") ?: continue val mode = resolveRoomNotifyMode(roomId, notifyCtx)
// Homeserver unread counts already apply push rules — skip rooms with nothing to notify.
val unread = roomData.optJSONObject("unread_notifications")
val notificationCount = unread?.optInt("notification_count", 0) ?: 0
if (mode == RoomNotifyMode.MUTE || notificationCount <= 0) continue
val mentionsOnly = mode == RoomNotifyMode.MENTIONS_AND_KEYWORDS
val timeline = roomData.optJSONObject("timeline") ?: continue
val events = timeline.optJSONArray("events") ?: continue val events = timeline.optJSONArray("events") ?: continue
for (i in 0 until events.length()) { for (i in 0 until events.length()) {
@@ -159,16 +199,259 @@ class MatrixSyncService : Service() {
if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue
val content = event.optJSONObject("content") ?: continue val content = event.optJSONObject("content") ?: continue
val body = content.optString("body").takeIf { it.isNotBlank() } ?: continue if (mentionsOnly && !isSpecialMessage(content, notifyCtx)) continue
val sender = event.optString("sender")
val senderName = sender.substringAfter("@").substringBefore(":")
showMessageNotification(nm, senderName, body) val msgtype = content.optString("msgtype")
val rawBody = content.optString("body")
val body = when (msgtype) {
"m.image" -> if (rawBody.isNotBlank()) "📷 $rawBody" else "📷 Photo"
"m.video" -> if (rawBody.isNotBlank()) "🎥 $rawBody" else "🎥 Video"
"m.audio" -> if (rawBody.isNotBlank()) "🎵 $rawBody" else "🎵 Audio"
"m.file" -> if (rawBody.isNotBlank()) "📎 $rawBody" else "📎 File"
"m.sticker" -> if (rawBody.isNotBlank()) "🖼️ $rawBody" else "🖼️ Sticker"
else -> rawBody.takeIf { it.isNotBlank() } ?: continue
}
val sender = event.optString("sender")
val profile = resolveProfile(sender, homeserver, token)
// For unencrypted image/sticker messages, try to download a preview bitmap.
// Encrypted messages have a `file` object instead of a top-level `url`.
val inlineImage: Bitmap? = if (
(msgtype == "m.image" || msgtype == "m.sticker") && !content.has("file")
) {
content.optString("url").takeIf { it.startsWith("mxc://") }
?.let { mxcToDownloadUrl(it, homeserver) }
?.let { downloadBitmap(it, token) }
} else null
showMessageNotification(nm, profile.displayName, body, profile.avatar, inlineImage)
} }
} }
} }
private fun showMessageNotification(nm: NotificationManager, sender: String, body: String) { /** Load push rules, DM list, keywords, and own display name for notification filtering. */
private fun loadNotifyContext(homeserver: String, token: String, myUserId: String): NotifyContext {
val pushRules = fetchJson("$homeserver/_matrix/client/v3/pushrules/", token)
val accountDataDirect = fetchJson("$homeserver/_matrix/client/v3/user/${urlEncode(myUserId)}/account_data/m.direct", token)
val profile = fetchJson("$homeserver/_matrix/client/v3/profile/${urlEncode(myUserId)}", token)
val directRoomIds = mutableSetOf<String>()
accountDataDirect?.keys()?.forEach { key ->
val rooms = accountDataDirect.optJSONArray(key) ?: return@forEach
for (i in 0 until rooms.length()) {
rooms.optString(i).takeIf { it.isNotBlank() }?.let { directRoomIds.add(it) }
}
}
val keywords = mutableListOf<String>()
val contentRules = pushRules?.optJSONObject("global")?.optJSONArray("content")
if (contentRules != null) {
for (i in 0 until contentRules.length()) {
val rule = contentRules.optJSONObject(i) ?: continue
if (rule.optBoolean("enabled", true).not()) continue
if (!actionsIncludeNotify(rule.optJSONArray("actions"))) continue
rule.optString("pattern").takeIf { it.isNotBlank() }?.let { keywords.add(it) }
}
}
return NotifyContext(
myUserId = myUserId,
myDisplayName = profile?.optString("displayname")?.takeIf { it.isNotBlank() },
myLocalpart = myUserId.substringAfter("@").substringBefore(":").takeIf { it.isNotBlank() },
keywords = keywords,
directRoomIds = directRoomIds,
pushRules = pushRules,
)
}
/**
* Mirrors Cinny/Paarrot room notification modes:
* Mute override → mute; room notify → all; room dont_notify → mentions;
* unset DM → all; unset room → mentions (app Default).
*/
private fun resolveRoomNotifyMode(roomId: String, ctx: NotifyContext): RoomNotifyMode {
val global = ctx.pushRules?.optJSONObject("global")
// If push rules are unavailable, rely on homeserver unread_notifications only.
?: return RoomNotifyMode.ALL_MESSAGES
val overrides = global.optJSONArray("override")
if (overrides != null) {
for (i in 0 until overrides.length()) {
val rule = overrides.optJSONObject(i) ?: continue
if (rule.optString("rule_id") != roomId) continue
if (rule.optBoolean("enabled", true).not()) continue
if (!actionsIncludeNotify(rule.optJSONArray("actions"))) {
return RoomNotifyMode.MUTE
}
}
}
val roomRules = global.optJSONArray("room")
if (roomRules != null) {
for (i in 0 until roomRules.length()) {
val rule = roomRules.optJSONObject(i) ?: continue
if (rule.optString("rule_id") != roomId) continue
if (rule.optBoolean("enabled", true).not()) continue
return if (actionsIncludeNotify(rule.optJSONArray("actions"))) {
RoomNotifyMode.ALL_MESSAGES
} else {
RoomNotifyMode.MENTIONS_AND_KEYWORDS
}
}
}
return if (ctx.directRoomIds.contains(roomId)) {
RoomNotifyMode.ALL_MESSAGES
} else {
RoomNotifyMode.MENTIONS_AND_KEYWORDS
}
}
private fun actionsIncludeNotify(actions: org.json.JSONArray?): Boolean {
if (actions == null) return false
for (i in 0 until actions.length()) {
when (val action = actions.opt(i)) {
is String -> if (action == "notify") return true
is JSONObject -> if (action.has("set_tweak")) { /* tweaks only */ }
}
}
return false
}
private fun isSpecialMessage(content: JSONObject, ctx: NotifyContext): Boolean {
val mentions = content.optJSONObject("m.mentions")
if (mentions != null) {
val userIds = mentions.optJSONArray("user_ids")
if (userIds != null) {
for (i in 0 until userIds.length()) {
if (userIds.optString(i) == ctx.myUserId) return true
}
}
if (mentions.optBoolean("room", false)) return true
}
val body = content.optString("body").lowercase()
if (body.isBlank()) return false
if (body.contains("@room")) return true
ctx.myDisplayName?.lowercase()?.takeIf { it.isNotBlank() }?.let {
if (body.contains(it)) return true
}
ctx.myLocalpart?.lowercase()?.let {
if (body.contains(it)) return true
}
for (keyword in ctx.keywords) {
if (body.contains(keyword.lowercase())) return true
}
return false
}
private fun urlEncode(value: String): String = URLEncoder.encode(value, "UTF-8")
private fun fetchJson(urlString: String, token: String): JSONObject? {
return try {
val conn = URL(urlString).openConnection() as HttpURLConnection
try {
conn.requestMethod = "GET"
conn.setRequestProperty("Authorization", "Bearer $token")
conn.setRequestProperty("Accept", "application/json")
conn.connectTimeout = 4_000
conn.readTimeout = 6_000
if (conn.responseCode != 200) return null
JSONObject(conn.inputStream.bufferedReader().readText())
} finally {
conn.disconnect()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch $urlString: ${e.message}")
null
}
}
private fun resolveProfile(mxid: String, homeserver: String, token: String): UserProfile {
val cachedName = displayNameCache[mxid]
if (cachedName != null) return UserProfile(cachedName, avatarCache[mxid])
val fallback = mxid.substringAfter("@").substringBefore(":")
return try {
val encodedId = URLEncoder.encode(mxid, "UTF-8")
val url = "$homeserver/_matrix/client/v3/profile/$encodedId"
val conn = URL(url).openConnection() as HttpURLConnection
val (displayName, avatarMxc) = try {
conn.requestMethod = "GET"
conn.setRequestProperty("Authorization", "Bearer $token")
conn.setRequestProperty("Accept", "application/json")
conn.connectTimeout = 4_000
conn.readTimeout = 4_000
if (conn.responseCode == 200) {
val body = conn.inputStream.bufferedReader().readText()
val obj = JSONObject(body)
val name = obj.optString("displayname").takeIf { it.isNotBlank() } ?: fallback
val mxc = obj.optString("avatar_url").takeIf { it.startsWith("mxc://") }
Pair(name, mxc)
} else {
Pair(fallback, null)
}
} finally {
conn.disconnect()
}
displayNameCache[mxid] = displayName
val avatar = avatarMxc
?.let { mxcToThumbnailUrl(it, homeserver, size = 96) }
?.let { downloadBitmap(it, token) }
avatarCache[mxid] = avatar
UserProfile(displayName, avatar)
} catch (e: Exception) {
Log.w(TAG, "Could not resolve profile for $mxid: ${e.message}")
displayNameCache[mxid] = fallback
avatarCache[mxid] = null
UserProfile(fallback, null)
}
}
private fun mxcToThumbnailUrl(mxcUrl: String, homeserver: String, size: Int): String? {
val withoutScheme = mxcUrl.removePrefix("mxc://")
val slash = withoutScheme.indexOf('/')
if (slash < 0) return null
val serverName = withoutScheme.substring(0, slash)
val mediaId = withoutScheme.substring(slash + 1)
return "$homeserver/_matrix/media/v3/thumbnail/$serverName/$mediaId?width=$size&height=$size&method=crop"
}
private fun mxcToDownloadUrl(mxcUrl: String, homeserver: String): String? {
val withoutScheme = mxcUrl.removePrefix("mxc://")
val slash = withoutScheme.indexOf('/')
if (slash < 0) return null
val serverName = withoutScheme.substring(0, slash)
val mediaId = withoutScheme.substring(slash + 1)
return "$homeserver/_matrix/media/v3/download/$serverName/$mediaId"
}
private fun downloadBitmap(urlString: String, token: String): Bitmap? {
return try {
val conn = URL(urlString).openConnection() as HttpURLConnection
try {
conn.requestMethod = "GET"
conn.setRequestProperty("Authorization", "Bearer $token")
conn.connectTimeout = 5_000
conn.readTimeout = 10_000
if (conn.responseCode == 200) BitmapFactory.decodeStream(conn.inputStream) else null
} finally {
conn.disconnect()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to download bitmap from $urlString: ${e.message}")
null
}
}
private fun showMessageNotification(
nm: NotificationManager,
sender: String,
body: String,
largeIcon: Bitmap? = null,
inlineImage: Bitmap? = null,
) {
val launchIntent = packageManager.getLaunchIntentForPackage(packageName) val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
?: Intent(this, MainActivity::class.java) ?: Intent(this, MainActivity::class.java)
val pi = PendingIntent.getActivity( val pi = PendingIntent.getActivity(
@@ -176,16 +459,25 @@ class MatrixSyncService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
val notification = NotificationCompat.Builder(this, CHANNEL_MESSAGES) val builder = NotificationCompat.Builder(this, CHANNEL_MESSAGES)
.setSmallIcon(R.drawable.ic_stat_paarrot) .setSmallIcon(R.drawable.ic_stat_paarrot)
.setContentTitle(sender) .setContentTitle(sender)
.setContentText(body) .setContentText(body)
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(pi) .setContentIntent(pi)
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
nm.notify(System.currentTimeMillis().toInt(), notification) if (largeIcon != null) builder.setLargeIcon(largeIcon)
if (inlineImage != null) {
builder.setStyle(
NotificationCompat.BigPictureStyle()
.bigPicture(inlineImage)
.bigLargeIcon(null as Bitmap?)
)
}
nm.notify(System.currentTimeMillis().toInt(), builder.build())
} }
private fun buildStatusNotification(): Notification { private fun buildStatusNotification(): Notification {
@@ -221,10 +513,20 @@ class MatrixSyncService : Service() {
private fun ensureMessageChannel(nm: NotificationManager) { private fun ensureMessageChannel(nm: NotificationManager) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val soundUri = Uri.parse("android.resource://$packageName/${R.raw.paarrot_notification}")
val channel = NotificationChannel( val channel = NotificationChannel(
CHANNEL_MESSAGES, "Messages", CHANNEL_MESSAGES, "Messages",
NotificationManager.IMPORTANCE_HIGH, NotificationManager.IMPORTANCE_HIGH,
).apply { description = "Matrix message notifications" } ).apply {
description = "Matrix message notifications"
setSound(
soundUri,
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build(),
)
}
nm.createNotificationChannel(channel) nm.createNotificationChannel(channel)
} }
} }
@@ -241,7 +543,7 @@ class MatrixSyncService : Service() {
private const val KEY_LAST_WAKE_MS = "last_wake_ms" private const val KEY_LAST_WAKE_MS = "last_wake_ms"
private const val NOTIF_ID_STATUS = 1001 private const val NOTIF_ID_STATUS = 1001
private const val CHANNEL_STATUS = "sync_status" private const val CHANNEL_STATUS = "sync_status"
private const val CHANNEL_MESSAGES = "messages" private const val CHANNEL_MESSAGES = "messages_paarrot"
private const val MIN_WAKE_INTERVAL_MS = 7_500L private const val MIN_WAKE_INTERVAL_MS = 7_500L
const val MODE_ONE_SHOT = "one_shot" const val MODE_ONE_SHOT = "one_shot"

View File

@@ -0,0 +1,250 @@
package com.paarrot.app
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.ZipInputStream
/** Capacitor plugin that stores and manages user extension plugins on Android. */
@CapacitorPlugin(name = "PluginStorage")
class PluginStoragePlugin : Plugin() {
private fun pluginsDir(): File {
val dir = File(context.filesDir, "plugins")
dir.mkdirs()
return dir
}
@PluginMethod
fun getPath(call: PluginCall) {
val result = JSObject()
result.put("success", true)
result.put("data", pluginsDir().absolutePath)
call.resolve(result)
}
@PluginMethod
fun list(call: PluginCall) {
try {
val plugins = pluginsDir().listFiles()?.filter { it.isDirectory } ?: emptyList()
val array = JSArray()
for (dir in plugins) {
val metadata = readMetadata(dir)
val obj = JSObject()
obj.put("id", dir.name)
obj.put("name", metadata?.optString("name", dir.name) ?: dir.name)
obj.put("version", metadata?.optString("version", "") ?: "")
obj.put("description", metadata?.optString("description", "") ?: "")
obj.put("author", metadata?.optString("author", "") ?: "")
obj.put("repository", metadata?.optString("repository", "") ?: "")
obj.put("thumbnail", metadata?.optString("thumbnail", "") ?: "")
obj.put("homepage", metadata?.optString("homepage", "") ?: "")
obj.put("tags", metadataTagsToJsArray(metadata))
obj.put("installedDate", formatInstalledDate(dir.lastModified()))
obj.put("path", dir.absolutePath)
array.put(obj)
}
val result = JSObject()
result.put("success", true)
result.put("data", array)
call.resolve(result)
} catch (e: Exception) {
val result = JSObject()
result.put("success", false)
result.put("error", e.message ?: "Failed to list plugins")
call.resolve(result)
}
}
@PluginMethod
fun download(call: PluginCall) {
val pluginId = call.getString("pluginId")
?: return call.reject("pluginId required")
val downloadUrl = call.getString("downloadUrl")
?: return call.reject("downloadUrl required")
val name = call.getString("name") ?: pluginId
Thread {
try {
val destDir = File(pluginsDir(), pluginId)
if (destDir.exists()) {
destDir.deleteRecursively()
}
destDir.mkdirs()
val tempZip = File(context.cacheDir, "plugin-download-$pluginId.zip")
downloadFile(downloadUrl, tempZip)
extractZip(tempZip, destDir)
tempZip.delete()
normalisePluginLayout(destDir)
if (!File(destDir, "index.js").exists()) {
val result = JSObject()
result.put("success", false)
result.put("error", "Downloaded plugin is missing index.js")
call.resolve(result)
return@Thread
}
val result = JSObject()
result.put("success", true)
val data = JSObject()
data.put("path", destDir.absolutePath)
result.put("data", data)
call.resolve(result)
} catch (e: Exception) {
val result = JSObject()
result.put("success", false)
result.put("error", e.message ?: "Failed to download plugin: $name")
call.resolve(result)
}
}.start()
}
@PluginMethod
fun uninstall(call: PluginCall) {
val pluginId = call.getString("pluginId")
?: return call.reject("pluginId required")
try {
val destDir = File(pluginsDir(), pluginId)
if (destDir.exists()) {
destDir.deleteRecursively()
}
val result = JSObject()
result.put("success", true)
call.resolve(result)
} catch (e: Exception) {
val result = JSObject()
result.put("success", false)
result.put("error", e.message ?: "Failed to uninstall plugin")
call.resolve(result)
}
}
@PluginMethod
fun readPluginCode(call: PluginCall) {
val pluginId = call.getString("pluginId")
?: return call.reject("pluginId required")
val indexFile = File(pluginsDir(), "$pluginId/index.js")
if (!indexFile.exists()) {
val result = JSObject()
result.put("success", false)
result.put("error", "Plugin not found: $pluginId")
call.resolve(result)
return
}
val result = JSObject()
result.put("success", true)
result.put("data", indexFile.readText())
call.resolve(result)
}
private fun readMetadata(pluginDir: File): JSONObject? {
val metadataFile = File(pluginDir, "plugin-metadata.json")
if (!metadataFile.exists()) return null
return try {
JSONObject(metadataFile.readText())
} catch (_: Exception) {
null
}
}
private fun metadataTagsToJsArray(metadata: JSONObject?): JSArray {
val tags = JSArray()
val jsonTags = metadata?.optJSONArray("tags") ?: return tags
for (i in 0 until jsonTags.length()) {
tags.put(jsonTags.optString(i))
}
return tags
}
private fun formatInstalledDate(timestamp: Long): String {
val formatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
return formatter.format(Date(timestamp))
}
private fun downloadFile(downloadUrl: String, destFile: File) {
val connection = (URL(downloadUrl).openConnection() as HttpURLConnection).apply {
connectTimeout = 30_000
readTimeout = 60_000
instanceFollowRedirects = true
requestMethod = "GET"
}
try {
connection.connect()
if (connection.responseCode !in 200..299) {
throw IllegalStateException("HTTP ${connection.responseCode}")
}
connection.inputStream.use { input ->
FileOutputStream(destFile).use { output ->
input.copyTo(output)
}
}
} finally {
connection.disconnect()
}
}
private fun extractZip(zipFile: File, destDir: File) {
destDir.mkdirs()
val destPath = destDir.canonicalPath + File.separator
ZipInputStream(zipFile.inputStream()).use { zipInput ->
var entry = zipInput.nextEntry
while (entry != null) {
val outFile = File(destDir, entry.name)
if (!outFile.canonicalPath.startsWith(destPath)) {
throw SecurityException("Zip entry outside target directory")
}
if (entry.isDirectory) {
outFile.mkdirs()
} else {
outFile.parentFile?.mkdirs()
FileOutputStream(outFile).use { output ->
zipInput.copyTo(output)
}
}
zipInput.closeEntry()
entry = zipInput.nextEntry
}
}
}
/** Flatten single-root zip layouts so index.js ends up directly under the plugin dir. */
private fun normalisePluginLayout(destDir: File) {
if (File(destDir, "index.js").exists()) return
val children = destDir.listFiles()?.filter { it.isDirectory } ?: return
if (children.size != 1) return
val nested = children[0]
nested.listFiles()?.forEach { child ->
child.renameTo(File(destDir, child.name))
}
nested.deleteRecursively()
}
}

View File

@@ -0,0 +1,216 @@
package com.paarrot.app
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import java.io.File
import java.io.FileOutputStream
/** Payload for a single shared file. */
data class SharedFilePayload(
val path: String,
val name: String,
val mimeType: String,
val size: Long,
)
/** Full payload delivered to JS when a share intent is received. */
data class SharePayload(
val text: String?,
val subject: String?,
val files: List<SharedFilePayload>,
val receivedAt: Long,
) {
/** Serialize to a [JSObject] for the Capacitor bridge. */
fun toJsObject(): JSObject {
val obj = JSObject()
obj.put("text", text)
obj.put("subject", subject)
val filesArray = JSArray()
for (f in files) {
val fileObj = JSObject()
fileObj.put("path", f.path)
fileObj.put("name", f.name)
fileObj.put("mimeType", f.mimeType)
fileObj.put("size", f.size)
filesArray.put(fileObj)
}
obj.put("files", filesArray)
obj.put("receivedAt", receivedAt)
return obj
}
}
/** Singleton that bridges between Android activity lifecycle and the Capacitor plugin. */
object ShareIntentStore {
var plugin: ShareHandlerPlugin? = null
var pendingShare: SharePayload? = null
/** Parse [intent] and, if it is a share action, store the payload and notify JS. */
fun handleIntent(context: Context, intent: Intent?) {
if (intent == null) return
val action = intent.action ?: return
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
val uris = mutableListOf<Uri>()
if (action == Intent.ACTION_SEND_MULTIPLE) {
@Suppress("UNCHECKED_CAST")
val list = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
}
list?.let { uris.addAll(it) }
} else {
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
uri?.let { uris.add(it) }
}
deliverShare(context, uris, text, subject)
}
/**
* Deliver content URIs from the IME (Gboard GIF), clipboard paste, or drag-drop
* into the same share pipeline used by ACTION_SEND.
*/
fun handleContentUris(context: Context, uris: List<Uri>) {
if (uris.isEmpty()) return
deliverShare(context, uris, text = null, subject = null)
}
private fun deliverShare(
context: Context,
uris: List<Uri>,
text: String?,
subject: String?,
) {
val receivedAt = System.currentTimeMillis()
val copiedFiles = uris.mapNotNull { copySharedUri(context, it, receivedAt) }
if (copiedFiles.isEmpty() && text.isNullOrBlank() && subject.isNullOrBlank()) return
val payload = SharePayload(
text = text,
subject = subject,
files = copiedFiles,
receivedAt = receivedAt,
)
pendingShare = payload
plugin?.emitShare(payload)
}
private fun copySharedUri(context: Context, uri: Uri, receivedAt: Long): SharedFilePayload? {
return try {
val resolver = context.contentResolver
val mimeType = resolver.getType(uri) ?: "application/octet-stream"
val originalName = resolver.query(uri, arrayOf("_display_name"), null, null, null)
?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null
} ?: uri.lastPathSegment ?: "shared_file"
val safeName = sanitizeFileName(originalName)
val destDir = File(context.cacheDir, "shared-intents/$receivedAt")
destDir.mkdirs()
var destFile = File(destDir, safeName)
var counter = 1
while (destFile.exists()) {
val dot = safeName.lastIndexOf('.')
val base = if (dot >= 0) safeName.substring(0, dot) else safeName
val ext = if (dot >= 0) safeName.substring(dot) else ""
destFile = File(destDir, "${base}_$counter$ext")
counter++
}
resolver.openInputStream(uri)?.use { input ->
FileOutputStream(destFile).use { output -> input.copyTo(output) }
}
SharedFilePayload(
path = destFile.absolutePath,
name = destFile.name,
mimeType = mimeType,
size = destFile.length(),
)
} catch (e: Exception) {
android.util.Log.e("ShareHandlerPlugin", "Failed to copy shared URI: $uri", e)
null
}
}
private fun sanitizeFileName(name: String): String {
return name.replace(Regex("[^a-zA-Z0-9._\\-]"), "_").take(128).ifEmpty { "file" }
}
/** Remove cached files from a previous share and clear the stored payload. */
fun clearIntent(receivedAt: Long?) {
if (receivedAt != null) {
val dir = plugin?.context?.let { File(it.cacheDir, "shared-intents/$receivedAt") }
dir?.deleteRecursively()
}
pendingShare = null
}
}
/** Capacitor plugin that exposes Android share intents to JavaScript. */
@CapacitorPlugin(name = "AndroidShareHandler")
class ShareHandlerPlugin : Plugin() {
override fun load() {
ShareIntentStore.plugin = this
ShareIntentStore.handleIntent(context, activity.intent)
}
override fun handleOnNewIntent(intent: Intent?) {
super.handleOnNewIntent(intent)
ShareIntentStore.handleIntent(context, intent)
}
/** Fire the `shareReceived` event toward the JS layer. */
fun emitShare(payload: SharePayload) {
notifyListeners("shareReceived", payload.toJsObject())
}
/** Returns the pending share payload (if any) as a JS object. */
@PluginMethod
fun getPendingShare(call: PluginCall) {
val pending = ShareIntentStore.pendingShare
if (pending == null) {
val result = JSObject()
result.put("share", JSObject.NULL)
call.resolve(result)
} else {
val result = JSObject()
result.put("share", pending.toJsObject())
call.resolve(result)
}
}
/** Clears the pending share and removes any copied temp files. */
@PluginMethod
fun clearPendingShare(call: PluginCall) {
ShareIntentStore.clearIntent(ShareIntentStore.pendingShare?.receivedAt)
call.resolve()
}
}

View File

@@ -2,15 +2,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportWidth="18"
android:viewportHeight="24"> android:viewportHeight="18">
<path <!--
android:fillColor="#FFFFFFFF" Converted from public/res/svg/notification.svg.
android:pathData="M2,9.1C3.8,5.7 7.2,4 11.6,4c4.4,0 7.8,1.7 10.4,5.1l-2,1.6c-1.7,-1.5 -3.8,-2.4 -6.4,-2.7c-0.2,1.6 -1.6,2.9 -3.3,2.9c-1.7,0 -3.1,-1.2 -3.3,-2.8C5.1,8.5 3.5,9.1 2,10z" /> Transforms flattened: outer(1.05922,0,0,1.05922,-0.5628,-0.4805)
<path x inner(1.6762,0,0,1.6762,-4.5071,-10.7901)
android:fillColor="#FFFFFFFF" = scale 1.7754, translate(-5.337,-11.908)
android:pathData="M8.7,11.5c-2.8,0 -5.1,2.3 -5.1,5.1c0,3 2.4,5.4 5.5,5.4c2.9,0 5.4,-1.3 7,-3.7c-0.5,0.1 -0.9,0.2 -1.4,0.2c-3.5,0 -6.4,-2.8 -6.4,-6.3c0,-0.2 0,-0.4 0,-0.7z" /> -->
<path <group
android:fillColor="#FFFFFFFF" android:scaleX="1.7754"
android:pathData="M14.7,12.3c3,0.1 5.3,1.6 7.3,4.2c-1.8,1.5 -4.1,2.4 -6.9,2.8c1,-1 1.6,-2.3 1.6,-3.8c0,-1.2 -0.3,-2.3 -0.9,-3.2z" /> android:scaleY="1.7754"
android:translateX="-5.337"
android:translateY="-11.908">
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M4.624,14.382C4.624,14.382 1.358,10.79 5.108,8.336C5.501,8.316 7.14,8.899 7.18,10.368C7.22,11.836 5.641,11.172 5.108,11.856C4.575,12.54 4.454,14.121 4.624,14.382Z" />
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M5.08,13.547C5.08,13.547 7.28,14.842 7.291,10.74C7.592,10.971 8.588,12.107 7.713,13.777C6.838,15.446 5.392,14.322 5.08,13.547Z" />
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M5.921,14.763C5.584,14.449 6.374,15.403 6.704,16.642C6.873,16.232 8.282,17.562 11.166,14.247C12.324,12.581 13.244,11.279 11.249,8.874C9.254,6.469 6.507,6.615 5.469,7.581C4.431,8.546 5.35,7.566 6.653,8.667C7.52,9.399 7.444,10.431 7.444,10.431C7.774,10.551 9.12,12.332 7.935,14.099C7.205,15.187 6.259,15.076 5.921,14.763Z" />
</group>
</vector> </vector>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.getcapacitor.BridgeActivity">
<com.paarrot.app.ImageKeyboardWebView
android:id="@+id/webview"
android:layout_width="fill_parent"
android:layout_height="fill_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

Binary file not shown.

View File

@@ -0,0 +1,4 @@
storeFile=../paarrot-release.keystore
storePassword=paarrot2026
keyAlias=paarrot
keyPassword=paarrot2026

Binary file not shown.

2
cinny

Submodule cinny updated: 7b69ed6df8...b52926f7d8

BIN
full_logcat.txt Normal file

Binary file not shown.

View File

@@ -1,3 +1,5 @@
{ {
"dependencies": {} "dependencies": {
"@capacitor/status-bar": "8.0.1"
}
} }

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { useCompactNav, useIsCompactListRoute } from '../hooks/useCompactNav';
import { MobileSwipeGestureHost } from './mobile/MobileSwipeGestureHost';
/**
* Outlet wrapper for route remounts + compact swipe-back chrome.
*
* On compact list routes the parent PageRoot already omits children. If this
* outlet is still mounted (stale stack / empty SpaceIndexRedirect), render
* nothing so an empty swipe shell cannot sit above the channel list and eat taps.
*/
export function AnimatedOutlet() {
const location = useLocation();
const compact = useCompactNav();
const isListRoute = useIsCompactListRoute();
if (compact && isListRoute) {
return null;
}
return (
<MobileSwipeGestureHost>
<div
key={location.pathname}
{...(!compact && { 'data-route-transition': 'true' })}
style={{
flex: 1,
minWidth: 0,
minHeight: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Outlet />
</div>
</MobileSwipeGestureHost>
);
}

View File

@@ -0,0 +1,79 @@
import { style } from '@vanilla-extract/css';
import { color, config, DefaultReset, toRem } from 'folds';
export const Editor = style([
DefaultReset,
{
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R400,
overflow: 'hidden',
selectors: {
// Keyboard-flush dock: only a top edge so the input can sit on the IME.
'[data-composer-flush="true"] &': {
borderRadius: 0,
boxShadow: `inset 0 ${config.borderWidth.B300} 0 0 ${color.SurfaceVariant.ContainerLine}`,
},
},
},
]);
export const EditorOptions = style([
DefaultReset,
{
padding: config.space.S200,
},
]);
export const EditorTextareaScroll = style({});
export const EditorTextarea = style([
DefaultReset,
{
flexGrow: 1,
height: '100%',
padding: `${toRem(13)} ${toRem(1)}`,
selectors: {
[`${EditorTextareaScroll}:first-child &`]: {
paddingLeft: toRem(13),
},
[`${EditorTextareaScroll}:last-child &`]: {
paddingRight: toRem(13),
},
'&:focus': {
outline: 'none',
},
},
},
]);
export const EditorPlaceholderContainer = style([
DefaultReset,
{
opacity: config.opacity.Placeholder,
pointerEvents: 'none',
userSelect: 'none',
},
]);
export const EditorPlaceholderTextVisual = style([
DefaultReset,
{
display: 'block',
paddingTop: toRem(13),
paddingLeft: toRem(1),
},
]);
export const EditorToolbarBase = style({
padding: `0 ${config.borderWidth.B300}`,
});
export const EditorToolbar = style({
padding: config.space.S100,
});
export const MarkdownBtnBox = style({
paddingRight: config.space.S100,
});

View File

@@ -0,0 +1,287 @@
/* eslint-disable no-param-reassign */
import React, {
ClipboardEventHandler,
KeyboardEventHandler,
ReactNode,
forwardRef,
useCallback,
useState,
useEffect,
useRef,
} from 'react';
import { Box, Scroll, Text } from 'folds';
import { Descendant, Editor, createEditor, Transforms, Range, Element as SlateElement, Text as SlateText, Point } from 'slate';
import {
Slate,
Editable,
withReact,
RenderLeafProps,
RenderElementProps,
RenderPlaceholderProps,
ReactEditor,
} from 'slate-react';
import { withHistory } from 'slate-history';
import { BlockType } from './types';
import { RenderElement, RenderLeaf } from './Elements';
import { CustomElement } from './slate';
import * as css from './Editor.css';
import { toggleKeyboardShortcut } from './keyboard';
import { createCommandElement } from './utils';
const initialValue: CustomElement[] = [
{
type: BlockType.Paragraph,
children: [{ text: '' }],
},
];
const withInline = (editor: Editor): Editor => {
const { isInline } = editor;
editor.isInline = (element) =>
[BlockType.Mention, BlockType.Emoticon, BlockType.Link, BlockType.Command].includes(
element.type
) || isInline(element);
return editor;
};
const withVoid = (editor: Editor): Editor => {
const { isVoid } = editor;
editor.isVoid = (element) =>
[BlockType.Mention, BlockType.Emoticon, BlockType.Command].includes(element.type) ||
isVoid(element);
return editor;
};
type CommandValidatorFn = (commandName: string) => boolean;
let commandValidator: CommandValidatorFn | null = null;
export const setCommandValidator = (validator: CommandValidatorFn) => {
commandValidator = validator;
};
const withCommandAutoConvert = (editor: Editor): Editor => {
// Removed auto-conversion on space to prevent focus issues
// Commands are now highlighted visually via decorations
// and executed when Enter is pressed
return editor;
};
export const useEditor = (): Editor => {
const [editor] = useState(() =>
withCommandAutoConvert(withInline(withVoid(withReact(withHistory(createEditor())))))
);
return editor;
};
export type EditorChangeHandler = (value: Descendant[]) => void;
type CustomEditorProps = {
editableName?: string;
top?: ReactNode;
bottom?: ReactNode;
before?: ReactNode;
after?: ReactNode;
maxHeight?: string;
editor: Editor;
placeholder?: string;
onKeyDown?: KeyboardEventHandler;
onKeyUp?: KeyboardEventHandler;
onChange?: EditorChangeHandler;
onPaste?: ClipboardEventHandler;
};
export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
(
{
editableName,
top,
bottom,
before,
after,
maxHeight = '50vh',
editor,
placeholder,
onKeyDown,
onKeyUp,
onChange,
onPaste,
},
ref
) => {
const renderElement = useCallback(
(props: RenderElementProps) => <RenderElement {...props} />,
[]
);
const renderLeaf = useCallback((props: RenderLeafProps) => <RenderLeaf {...props} />, []);
const decorate = useCallback(([node, path]: [any, number[]]) => {
const ranges: any[] = [];
// Only decorate text nodes in the first paragraph
if (
path.length === 2 &&
path[0] === 0 &&
path[1] === 0 &&
SlateText.isText(node)
) {
const firstChild = editor.children[0];
if (SlateElement.isElement(firstChild) && firstChild.type === BlockType.Paragraph) {
const [firstInline, secondInline] = firstChild.children;
// Don't decorate if we already have a CommandElement
if (SlateElement.isElement(secondInline) && secondInline.type === BlockType.Command) {
return ranges;
}
// Check if the text matches /command pattern
const text = node.text;
const match = text.match(/^(\s*\/\w+)/);
if (match && commandValidator) {
const commandText = match[1].trim();
const commandName = commandText.substring(1);
if (commandValidator(commandName)) {
// Create decoration for the command text
ranges.push({
anchor: { path, offset: 0 },
focus: { path, offset: match[1].length },
pendingCommand: true,
});
}
}
}
}
return ranges;
}, [editor]);
const handleKeydown: KeyboardEventHandler = useCallback(
(evt) => {
onKeyDown?.(evt);
const shortcutToggled = toggleKeyboardShortcut(editor, evt);
if (shortcutToggled) evt.preventDefault();
},
[editor, onKeyDown]
);
const handleBeforeInput = useCallback(
(event: Event) => {
const inputEvent = event as InputEvent;
// Handle mobile autocorrect replacement that causes text duplication in Slate
if (inputEvent.inputType === 'insertReplacementText') {
const data = inputEvent.data || inputEvent.dataTransfer?.getData('text/plain');
if (!data) return;
event.preventDefault();
const domRanges = inputEvent.getTargetRanges?.() ?? [];
if (domRanges.length > 0) {
const slateRange = ReactEditor.toSlateRange(editor, domRanges[0], {
exactMatch: false,
suppressThrow: true,
});
if (slateRange) {
Transforms.select(editor, slateRange);
Transforms.delete(editor);
editor.insertText(data);
return;
}
}
const { selection } = editor;
if (!selection) return;
if (!Range.isCollapsed(selection)) {
Transforms.delete(editor, { at: selection });
} else {
const wordBefore = Editor.before(editor, selection.anchor, { unit: 'word' });
if (wordBefore) {
const wordRange = { anchor: wordBefore, focus: selection.anchor };
Transforms.select(editor, wordRange);
Transforms.delete(editor);
}
}
editor.insertText(data);
}
},
[editor]
);
const editableRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const editableElement = editableRef.current?.querySelector('[data-slate-editor="true"]');
if (!editableElement) return;
editableElement.addEventListener('beforeinput', handleBeforeInput, { capture: true });
return () => {
editableElement.removeEventListener('beforeinput', handleBeforeInput, { capture: true });
};
}, [handleBeforeInput]);
const renderPlaceholder = useCallback(
({ attributes, children }: RenderPlaceholderProps) => (
<span {...attributes} className={css.EditorPlaceholderContainer}>
{/* Inner component to style the actual text position and appearance */}
<Text as="span" className={css.EditorPlaceholderTextVisual} truncate>
{children}
</Text>
</span>
),
[]
);
return (
<div className={css.Editor} ref={ref}>
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
{top}
<Box alignItems="Start">
{before && (
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
{before}
</Box>
)}
<Scroll
className={css.EditorTextareaScroll}
variant="SurfaceVariant"
style={{ maxHeight }}
size="300"
visibility="Hover"
hideTrack
ref={editableRef}
>
<Editable
data-editable-name={editableName}
className={css.EditorTextarea}
placeholder={placeholder}
renderPlaceholder={renderPlaceholder}
renderElement={renderElement}
renderLeaf={renderLeaf}
decorate={decorate}
onKeyDown={handleKeydown}
onKeyUp={onKeyUp}
onPaste={onPaste}
/>
</Scroll>
{after && (
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
{after}
</Box>
)}
</Box>
{bottom}
</Slate>
</div>
);
}
);

View File

@@ -0,0 +1,367 @@
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { useCompactNav } from '../../hooks/useCompactNav';
import { useBackRoute } from '../../hooks/useBackRoute';
import { startReplyToEvent } from '../../features/room/replyToMessage';
import { mobileSwipeReplyBridgeRef } from './mobileSwipeReplyBridge';
import * as css from './mobile-gestures.css';
const EDGE_WIDTH = 28;
const BACK_COMMIT_RATIO = 0.25;
const BACK_COMMIT_MIN = 56;
const REPLY_THRESHOLD = 48;
const REPLY_MAX = 96;
const AXIS_LOCK_PX = 10;
const AXIS_DOMINANCE = 1.2;
const MAX_START_Y_RATIO = 0.88;
type GesturePhase = 'idle' | 'pending' | 'back' | 'reply';
type GestureState = {
phase: GesturePhase;
touchId: number;
startX: number;
startY: number;
messageEl: HTMLElement | null;
transformEl: HTMLElement | null;
messageId: string | null;
edgeBack: boolean;
offset: number;
};
const IDLE_STATE: GestureState = {
phase: 'idle',
touchId: -1,
startX: 0,
startY: 0,
messageEl: null,
transformEl: null,
messageId: null,
edgeBack: false,
offset: 0,
};
type MobileSwipeGestureHostProps = {
children: ReactNode;
};
const shouldIgnoreBackTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return true;
return Boolean(
target.closest(
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-back="true"]'
)
);
};
const shouldIgnoreReplyTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return true;
return Boolean(
target.closest(
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-reply="true"]'
)
);
};
const findMessageElement = (target: EventTarget | null): HTMLElement | null => {
if (!(target instanceof Element)) return null;
return target.closest('[data-message-id]') as HTMLElement | null;
};
const findReplyTransformTarget = (messageEl: HTMLElement): HTMLElement => {
const content = messageEl.querySelector('[data-swipe-reply-content]');
if (content instanceof HTMLElement) return content;
return messageEl;
};
const applyBackOffset = (dx: number, width: number): number => {
const positive = Math.max(0, dx);
if (positive <= width) return positive;
return width + (positive - width) * 0.22;
};
const setElementTransform = (el: HTMLElement | null, px: number) => {
if (!el) return;
el.style.transition = 'none';
el.style.transform = `translateX(${px}px)`;
};
const resetElementTransform = (el: HTMLElement | null) => {
if (!el) return;
el.style.transition = 'none';
el.style.transform = '';
};
export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps) {
const compact = useCompactNav();
const { pathname } = useLocation();
const { canGoBack, goBack } = useBackRoute();
const backEnabled = compact && canGoBack;
const replyEnabled = compact;
const rootRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const gestureRef = useRef<GestureState>({ ...IDLE_STATE });
const scrollLockRef = useRef<HTMLElement | null>(null);
const unlockScroll = useCallback(() => {
if (scrollLockRef.current) {
scrollLockRef.current.style.overflow = '';
scrollLockRef.current = null;
}
}, []);
const lockScroll = useCallback((anchor: HTMLElement) => {
unlockScroll();
let el: HTMLElement | null = anchor;
while (el && el !== document.body) {
const style = window.getComputedStyle(el);
if (
(style.overflowY === 'auto' || style.overflowY === 'scroll') &&
el.scrollHeight > el.clientHeight + 1
) {
scrollLockRef.current = el;
el.style.overflow = 'hidden';
return;
}
el = el.parentElement;
}
}, [unlockScroll]);
const resetBackTransform = useCallback(() => {
resetElementTransform(contentRef.current);
document.documentElement.classList.remove('mobile-gesture-lock');
}, []);
const resetReplyTransform = useCallback((transformEl?: HTMLElement | null) => {
const bridge = mobileSwipeReplyBridgeRef.current;
const el = transformEl ?? gestureRef.current.transformEl;
resetElementTransform(el);
bridge?.setIndicator(null, false);
document.documentElement.classList.remove('mobile-gesture-lock');
}, []);
const resetGesture = useCallback(
(transformEl?: HTMLElement | null) => {
resetBackTransform();
resetReplyTransform(transformEl);
unlockScroll();
gestureRef.current = { ...IDLE_STATE };
},
[resetBackTransform, resetReplyTransform, unlockScroll]
);
const updateReplyIndicator = useCallback((messageEl: HTMLElement, offset: number) => {
const bridge = mobileSwipeReplyBridgeRef.current;
if (!bridge?.layerEl) return;
const messageRect = messageEl.getBoundingClientRect();
const layerRect = bridge.layerEl.getBoundingClientRect();
const top = messageRect.top - layerRect.top + messageRect.height / 2 - 18;
bridge.setIndicator(top, Math.abs(offset) >= REPLY_THRESHOLD * 0.6);
}, []);
const applyReplyTransform = useCallback(
(transformEl: HTMLElement, messageEl: HTMLElement, offset: number) => {
setElementTransform(transformEl, offset);
updateReplyIndicator(messageEl, offset);
},
[updateReplyIndicator]
);
const lockGesture = useCallback(
(phase: 'back' | 'reply', scrollAnchor?: HTMLElement | null) => {
gestureRef.current.phase = phase;
document.documentElement.classList.add('mobile-gesture-lock');
if (scrollAnchor) {
lockScroll(scrollAnchor);
}
},
[lockScroll]
);
const finishGesture = useCallback(() => {
const gesture = gestureRef.current;
if (gesture.phase === 'idle' || gesture.phase === 'pending') {
resetGesture();
return;
}
if (gesture.phase === 'back') {
const width = rootRef.current?.clientWidth ?? window.innerWidth;
const shouldCommit =
gesture.offset >= Math.max(width * BACK_COMMIT_RATIO, BACK_COMMIT_MIN);
resetGesture();
if (shouldCommit) {
goBack();
}
return;
}
if (gesture.phase === 'reply' && gesture.messageEl && gesture.messageId) {
const bridge = mobileSwipeReplyBridgeRef.current;
const shouldReply = Math.abs(gesture.offset) >= REPLY_THRESHOLD;
const { messageEl, messageId, transformEl } = gesture;
resetGesture(transformEl);
if (shouldReply && bridge) {
startReplyToEvent(bridge.room, messageId, bridge.setReplyDraft, bridge.editor);
}
}
}, [goBack, resetGesture]);
useEffect(() => {
if (!compact) return;
const getActiveTouch = (evt: TouchEvent, touchId: number) =>
Array.from(evt.touches).find((touch) => touch.identifier === touchId);
const getEndedTouch = (evt: TouchEvent, touchId: number) =>
Array.from(evt.changedTouches).find((touch) => touch.identifier === touchId);
const isInsideSwipeHost = (target: EventTarget | null): boolean => {
const root = rootRef.current;
if (!root || !(target instanceof Node)) return false;
return root.contains(target);
};
const handleTouchStart = (evt: TouchEvent) => {
if (gestureRef.current.phase !== 'idle') return;
const touch = evt.changedTouches[0];
if (!touch) return;
const target = evt.target;
if (touch.clientY > window.innerHeight * MAX_START_Y_RATIO) return;
const messageEl = findMessageElement(target);
const messageId = messageEl?.getAttribute('data-message-id') ?? null;
// Only steal touches that start inside the detail/swipe host. Document-level
// listeners otherwise fight the always-visible sidebar (space icons sit in the
// former screen-left EDGE_WIDTH zone and lost clicks to preventDefault).
const insideHost = isInsideSwipeHost(target);
const canStartBack =
backEnabled && insideHost && !shouldIgnoreBackTarget(target);
const canStartReply =
replyEnabled &&
messageEl &&
messageId &&
!shouldIgnoreReplyTarget(target) &&
mobileSwipeReplyBridgeRef.current;
if (!canStartBack && !canStartReply) return;
const contentLeft = rootRef.current?.getBoundingClientRect().left ?? 0;
const nearContentLeftEdge =
touch.clientX >= contentLeft && touch.clientX <= contentLeft + EDGE_WIDTH;
// Prefer pending over immediate back+preventDefault so a tap still clicks.
gestureRef.current = {
phase: 'pending',
touchId: touch.identifier,
startX: touch.clientX,
startY: touch.clientY,
messageEl: canStartReply ? messageEl : null,
transformEl: canStartReply && messageEl ? findReplyTransformTarget(messageEl) : null,
messageId: canStartReply ? messageId : null,
edgeBack: nearContentLeftEdge && canStartBack,
offset: 0,
};
};
const handleTouchMove = (evt: TouchEvent) => {
const gesture = gestureRef.current;
if (gesture.phase === 'idle') return;
const touch = getActiveTouch(evt, gesture.touchId);
if (!touch) return;
const deltaX = touch.clientX - gesture.startX;
const deltaY = touch.clientY - gesture.startY;
if (gesture.phase === 'pending') {
if (Math.abs(deltaX) < AXIS_LOCK_PX && Math.abs(deltaY) < AXIS_LOCK_PX) return;
const horizontal = Math.abs(deltaX) >= Math.abs(deltaY) * AXIS_DOMINANCE;
const vertical = Math.abs(deltaY) >= Math.abs(deltaX) * AXIS_DOMINANCE;
if (vertical) {
resetGesture();
return;
}
if (!horizontal) return;
if (deltaX > 0 && backEnabled) {
lockGesture('back', contentRef.current);
} else if (deltaX < 0 && gesture.messageEl && gesture.messageId && gesture.transformEl) {
lockGesture('reply', gesture.messageEl);
} else {
resetGesture();
return;
}
}
if (gesture.phase === 'back') {
if (!evt.cancelable) return;
evt.preventDefault();
const width = rootRef.current?.clientWidth ?? window.innerWidth;
const offset = applyBackOffset(deltaX, width);
gesture.offset = offset;
setElementTransform(contentRef.current, offset);
return;
}
if (gesture.phase === 'reply' && gesture.transformEl && gesture.messageEl) {
if (!evt.cancelable) return;
evt.preventDefault();
const offset = Math.max(deltaX, -REPLY_MAX);
gesture.offset = offset;
applyReplyTransform(gesture.transformEl, gesture.messageEl, offset);
}
};
const handleTouchEnd = (evt: TouchEvent) => {
const gesture = gestureRef.current;
if (gesture.phase === 'idle') return;
const touch = getEndedTouch(evt, gesture.touchId);
if (!touch) return;
finishGesture();
};
document.addEventListener('touchstart', handleTouchStart, { capture: true, passive: false });
document.addEventListener('touchmove', handleTouchMove, { capture: true, passive: false });
document.addEventListener('touchend', handleTouchEnd, { capture: true });
document.addEventListener('touchcancel', handleTouchEnd, { capture: true });
return () => {
document.removeEventListener('touchstart', handleTouchStart, { capture: true });
document.removeEventListener('touchmove', handleTouchMove, { capture: true });
document.removeEventListener('touchend', handleTouchEnd, { capture: true });
document.removeEventListener('touchcancel', handleTouchEnd, { capture: true });
resetGesture();
};
}, [backEnabled, compact, finishGesture, lockGesture, replyEnabled, resetGesture, applyReplyTransform]);
useEffect(() => {
resetGesture();
}, [canGoBack, pathname, resetGesture]);
if (!compact) {
return <>{children}</>;
}
return (
<div ref={rootRef} className={css.SwipeBackRoot}>
<div ref={contentRef} className={css.SwipeBackContent}>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import React, { ReactNode, useLayoutEffect, useRef, useState } from 'react';
import { Room } from 'matrix-js-sdk';
import { Editor } from 'slate';
import { useSetAtom } from 'jotai';
import { Icon, Icons } from '../icons';
import { useCompactNav } from '../../hooks/useCompactNav';
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { mobileSwipeReplyBridgeRef } from './mobileSwipeReplyBridge';
import * as css from './mobile-gestures.css';
type MobileSwipeToReplyLayerProps = {
room: Room;
editor: Editor;
children: ReactNode;
};
export function MobileSwipeToReplyLayer({ room, editor, children }: MobileSwipeToReplyLayerProps) {
const compact = useCompactNav();
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
const layerRef = useRef<HTMLDivElement>(null);
const [indicatorTop, setIndicatorTop] = useState<number | null>(null);
const [indicatorActive, setIndicatorActive] = useState(false);
useLayoutEffect(() => {
if (!compact) return;
mobileSwipeReplyBridgeRef.current = {
room,
editor,
setReplyDraft,
layerEl: layerRef.current,
setIndicator: (top, active) => {
setIndicatorTop(top);
setIndicatorActive(active);
},
};
return () => {
if (mobileSwipeReplyBridgeRef.current?.room.roomId === room.roomId) {
mobileSwipeReplyBridgeRef.current = null;
}
setIndicatorTop(null);
setIndicatorActive(false);
};
}, [compact, editor, room, setReplyDraft]);
if (!compact) {
return <>{children}</>;
}
return (
<div ref={layerRef} className={css.SwipeToReplyLayer}>
{children}
{indicatorTop !== null && (
<div
className={`${css.SwipeToReplyIndicator} ${
indicatorActive ? css.SwipeToReplyIndicatorActive : ''
}`}
style={{ top: indicatorTop }}
aria-hidden
>
<Icon src={Icons.ReplyArrow} size="200" />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { globalStyle, style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
export const SwipeBackRoot = style({
position: 'relative',
flex: 1,
minWidth: 0,
minHeight: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
});
export const SwipeBackContent = style({
position: 'relative',
flex: 1,
minWidth: 0,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
backgroundColor: color.Background.Container,
boxShadow: '-4px 0 16px rgba(0, 0, 0, 0.28)',
willChange: 'transform',
touchAction: 'pan-y',
});
export const CompactDetailRoot = style({
position: 'relative',
minWidth: 0,
overflow: 'hidden',
});
export const CompactNavUnderlay = style({
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
zIndex: 0,
// Keep underlay tappable if the detail layer is empty/transparent
// (e.g. space index after leaving DMs). Opaque detail content still
// sits above and receives taps first.
pointerEvents: 'auto',
});
export const CompactDetailLayer = style({
position: 'relative',
zIndex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
// Pass through taps when outlet content is null/empty; real pages
// re-enable pointer events on their root via the child rule below.
pointerEvents: 'none',
});
globalStyle(`${CompactDetailLayer} > *`, {
pointerEvents: 'auto',
flex: 1,
minHeight: 0,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
backgroundColor: color.Background.Container,
});
export const SwipeToReplyLayer = style({
position: 'relative',
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
});
export const SwipeToReplyIndicator = style({
position: 'absolute',
right: config.space.S300,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: toRem(36),
height: toRem(36),
borderRadius: config.radii.Pill,
backgroundColor: color.Primary.Container,
color: color.Primary.OnContainer,
pointerEvents: 'none',
zIndex: 2,
opacity: 0,
transform: 'scale(0.85)',
transition: 'opacity 0.12s ease, transform 0.12s ease',
});
export const SwipeToReplyIndicatorActive = style({
opacity: 1,
transform: 'scale(1)',
});

View File

@@ -0,0 +1,15 @@
import { Room } from 'matrix-js-sdk';
import { Editor } from 'slate';
import { IReplyDraft } from '../../state/room/roomInputDrafts';
export type MobileSwipeReplyBridge = {
room: Room;
editor: Editor;
setReplyDraft: (draft: IReplyDraft | undefined) => void;
layerEl: HTMLElement | null;
setIndicator: (top: number | null, active: boolean) => void;
};
export const mobileSwipeReplyBridgeRef: { current: MobileSwipeReplyBridge | null } = {
current: null,
};

View File

@@ -0,0 +1,214 @@
import React, { ComponentProps, MutableRefObject, ReactNode } from 'react';
import { Box, Header, Line, Scroll, Text, as } from 'folds';
import classNames from 'classnames';
import { ContainerColor } from '../../styles/ContainerColor.css';
import * as css from './style.css';
import * as swipeCss from '../mobile/mobile-gestures.css';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
import { useShowCompactMasterView } from '../../hooks/useCompactNav';
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
type PageRootProps = {
nav: ReactNode;
children: ReactNode;
};
export function PageRoot({ nav, children }: PageRootProps) {
const screenSize = useScreenSizeContext();
const showCompactMaster = useShowCompactMasterView();
const compact = screenSize === ScreenSize.Mobile;
// Master list routes: only the channel/space list is interactive.
// Never mount the detail/underlay stack here — an empty detail layer would
// sit above the list with pointer-events and freeze taps (DM → space bug).
if (compact && showCompactMaster) {
return (
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
{nav}
</Box>
);
}
if (compact && !showCompactMaster) {
return (
<Box
grow="Yes"
className={classNames(ContainerColor({ variant: 'Background' }), swipeCss.CompactDetailRoot)}
>
{nav && (
<Box className={swipeCss.CompactNavUnderlay} aria-hidden>
{nav}
</Box>
)}
<Box grow="Yes" className={swipeCss.CompactDetailLayer}>
{children}
</Box>
</Box>
);
}
return (
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
{nav}
{screenSize !== ScreenSize.Mobile && (
<Line variant="Background" size="300" direction="Vertical" />
)}
{children}
</Box>
);
}
type ClientDrawerLayoutProps = {
children: ReactNode;
};
export function PageNav({
size,
children,
className,
}: ClientDrawerLayoutProps & css.PageNavVariants & { className?: string }) {
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
return (
<Box
grow={isMobile ? 'Yes' : undefined}
className={classNames(css.PageNav({ size }), className)}
shrink={isMobile ? 'Yes' : 'No'}
data-page-nav=""
>
<Box grow="Yes" direction="Column">
{children}
<SidebarDockedCallPanel />
</Box>
</Box>
);
}
export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
({ className, outlined, ...props }, ref) => (
<Header
className={classNames(css.PageNavHeader({ outlined }), className)}
variant="Background"
size="600"
data-folder-tab="nav"
{...props}
ref={ref}
/>
)
);
export function PageNavContent({
scrollRef,
scrollProps,
children,
}: {
children: ReactNode;
scrollRef?: MutableRefObject<HTMLDivElement | null>;
scrollProps?: React.ComponentProps<typeof Scroll>;
}) {
const theme = useTheme();
const hideScrollbar = isStationeryTheme(theme);
return (
<Box grow="Yes" direction="Column">
<Scroll
ref={scrollRef}
variant="Background"
direction="Vertical"
size={hideScrollbar ? '0' : '300'}
hideTrack
visibility="Hover"
{...scrollProps}
>
<div className={css.PageNavContent}>{children}</div>
</Scroll>
</Box>
);
}
export const Page = as<'div'>(({ className, ...props }, ref) => (
<Box
grow="Yes"
direction="Column"
className={classNames(ContainerColor({ variant: 'Surface' }), className)}
data-page=""
{...props}
ref={ref}
/>
));
export const PageHeader = as<'div', css.PageHeaderVariants>(
({ className, outlined, balance, ...props }, ref) => (
<Header
as="header"
size="600"
className={classNames(css.PageHeader({ balance, outlined }), className)}
data-folder-tab-bar=""
{...props}
ref={ref}
/>
)
);
export const PageContent = as<'div'>(({ className, ...props }, ref) => (
<div className={classNames(css.PageContent, className)} {...props} ref={ref} />
));
export function PageHeroEmpty({ children }: { children: ReactNode }) {
return (
<Box
className={classNames(ContainerColor({ variant: 'SurfaceVariant' }), css.PageHeroEmpty)}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="200"
>
{children}
</Box>
);
}
export const PageHeroSection = as<'div', ComponentProps<typeof Box>>(
({ className, ...props }, ref) => (
<Box
direction="Column"
className={classNames(css.PageHeroSection, className)}
{...props}
ref={ref}
/>
)
);
export function PageHero({
icon,
title,
subTitle,
children,
}: {
icon: ReactNode;
title: ReactNode;
subTitle: ReactNode;
children?: ReactNode;
}) {
return (
<Box direction="Column" gap="400">
<Box direction="Column" alignItems="Center" gap="200">
{icon}
</Box>
<Box as="h2" direction="Column" gap="200" alignItems="Center">
<Text align="Center" size="H2">
{title}
</Text>
<Text align="Center" priority="400">
{subTitle}
</Text>
</Box>
{children}
</Box>
);
}
export const PageContentCenter = as<'div'>(({ className, ...props }, ref) => (
<div className={classNames(css.PageContentCenter, className)} {...props} ref={ref} />
));

View File

@@ -0,0 +1,316 @@
import { useAtomValue } from 'jotai';
import React, { ChangeEventHandler, useCallback, useMemo, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import FocusTrap from 'focus-trap-react';
import {
Avatar,
Box,
Button,
Header,
Icon,
IconButton,
Icons,
Input,
MenuItem,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Text,
config,
} from 'folds';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom';
import { allRoomsAtom } from '../state/room-list/roomList';
import { mDirectAtom } from '../state/mDirectList';
import { useDirects, useRooms } from '../state/hooks/roomList';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../utils/room';
import { RoomAvatar, RoomIcon } from '../components/room-avatar';
import { nameInitials } from '../utils/common';
import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
import { factoryRoomIdByActivity } from '../utils/sort';
import {
SearchItemStrGetter,
useAsyncSearch,
UseAsyncSearchOptions,
} from '../hooks/useAsyncSearch';
import { VirtualTile } from '../components/virtualizer';
import { AndroidSharePayload } from '../utils/androidShare';
import { stopPropagation } from '../utils/keyboard';
const SEARCH_OPTS: UseAsyncSearchOptions = {
limit: 200,
matchOptions: {
contain: true,
},
};
type ShareRoomPickerProps = {
/** The pending share payload to display a preview of. */
share: AndroidSharePayload;
/** Called when the user picks a room. */
onPick: (roomId: string) => void;
/** Called when the user dismisses without picking. */
onDismiss: () => void;
};
/**
* Full-screen modal that lets the user pick a room to send
* an incoming Android share intent into.
*/
export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const scrollRef = useRef<HTMLDivElement>(null);
const [confirmRoomId, setConfirmRoomId] = useState<string | null>(null);
const mDirects = useAtomValue(mDirectAtom);
const allRoomsSet = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allRoomsSet);
const rooms = useRooms(mx, allRoomsAtom, mDirects);
const directs = useDirects(mx, allRoomsAtom, mDirects);
const allItems = useMemo(
() => [...rooms, ...directs].sort(factoryRoomIdByActivity(mx)),
[mx, rooms, directs]
);
const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
(rId) => getRoom(rId)?.name ?? rId,
[getRoom]
);
const [searchResult, searchRoom, resetSearch] = useAsyncSearch(
allItems,
getRoomNameStr,
SEARCH_OPTS
);
const items = searchResult ? searchResult.items : allItems;
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 48,
overscan: 8,
});
const vItems = virtualizer.getVirtualItems();
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const value = evt.currentTarget.value.trim();
if (!value) {
resetSearch();
return;
}
searchRoom(value);
};
const handleRoomClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
const roomId = evt.currentTarget.getAttribute('data-room-id');
if (roomId) {
setConfirmRoomId(roomId);
}
};
const handleConfirm = useCallback(() => {
if (confirmRoomId) {
onPick(confirmRoomId);
}
}, [confirmRoomId, onPick]);
const handleCancelConfirm = useCallback(() => {
setConfirmRoomId(null);
}, []);
const previewText = share.text?.slice(0, 80) ?? share.subject?.slice(0, 80);
const fileCount = share.files.length;
return (
<>
<Overlay open>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: onDismiss,
escapeDeactivates: stopPropagation,
}}
>
<Box
grow="Yes"
direction="Column"
style={{
position: 'fixed',
inset: 0,
zIndex: 999,
backgroundColor: '#1a1d23',
paddingTop: '60px',
}}
>
<Header
size="500"
style={{ padding: config.space.S400 }}
>
<Box grow="Yes" direction="Column" gap="100" style={{ minWidth: 0, maxWidth: '80%' }}>
<Text size="H4">Share to Room</Text>
{(previewText || fileCount > 0) && (
<Text size="T200" priority="300" style={{ opacity: 0.7, wordBreak: 'break-word', whiteSpace: 'normal' }}>
{previewText
? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"`
: `${fileCount} file${fileCount !== 1 ? 's' : ''}`}
</Text>
)}
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={onDismiss}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Header>
<Box
style={{
padding: config.space.S200,
paddingTop: config.space.S300,
}}
>
<Input
onChange={handleSearchChange}
before={<Icon size="200" src={Icons.Search} />}
placeholder="Search rooms…"
size="400"
variant="Background"
outlined
autoFocus
style={{ width: '100%' }}
/>
</Box>
<Scroll ref={scrollRef} size="300" hideTrack style={{ flex: 1, minHeight: 0 }}>
<Box style={{ padding: config.space.S300 }} direction="Column">
{vItems.length === 0 && (
<Box
style={{ padding: `${config.space.S700} 0` }}
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="100"
>
<Text size="H6" align="Center">
{searchResult ? 'No Match Found' : 'No Rooms'}
</Text>
<Text size="T200" align="Center" priority="300">
{searchResult
? `No rooms found for "${searchResult.query}".`
: 'You have no rooms to share into.'}
</Text>
</Box>
)}
<Box style={{ position: 'relative', height: virtualizer.getTotalSize() }}>
{vItems.map((vItem) => {
const roomId = items[vItem.index];
const room = getRoom(roomId);
if (!room) return null;
const isDm = mDirects.has(roomId);
const avatarSrc = isDm
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingBottom: config.space.S100 }}
ref={virtualizer.measureElement}
key={roomId}
>
<MenuItem
data-room-id={roomId}
onClick={handleRoomClick}
variant="Surface"
size="400"
radii="400"
before={
<Avatar size="300" radii={isDm ? '400' : '300'}>
{avatarSrc ? (
<RoomAvatar
roomId={roomId}
src={avatarSrc}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/>
) : (
<RoomIcon room={room} size="200" filled />
)}
</Avatar>
}
>
<Box grow="Yes" direction="Column">
<Text size="B400" truncate>
{room.name}
</Text>
</Box>
</MenuItem>
</VirtualTile>
);
})}
</Box>
</Box>
</Scroll>
</Box>
</FocusTrap>
</Overlay>
{confirmRoomId && (() => {
const room = getRoom(confirmRoomId);
const roomName = room?.name ?? confirmRoomId;
const isDm = mDirects.has(confirmRoomId);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleCancelConfirm,
escapeDeactivates: stopPropagation,
}}
>
<Modal style={{ maxWidth: '280px' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S300 }}>
<Box direction="Column" gap="100">
<Text size="H4">Share to {isDm ? 'Person' : 'Room'}?</Text>
<Text size="T300" priority="300">
Send to <strong>{roomName}</strong>?
</Text>
</Box>
<Box gap="200" justifyContent="End">
<Button
onClick={handleCancelConfirm}
variant="Secondary"
size="400"
>
<Text size="B400">Cancel</Text>
</Button>
<Button
onClick={handleConfirm}
variant="Primary"
size="400"
>
<Text size="B400">Share</Text>
</Button>
</Box>
</Box>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
})()}
</>
);
}

View File

@@ -0,0 +1,136 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Scroll, Text, color } from 'folds';
import { Icon, Icons } from '../../components/icons';
import classNames from 'classnames';
import { useSetAtom } from 'jotai';
import { useNavigate } from 'react-router-dom';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useCompactNav } from '../../hooks/useCompactNav';
import { Page, PageContent } from '../../components/page';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { activeThreadIdAtomFamily } from '../../state/activeThread';
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
import { getSpacePath } from '../../pages/pathUtils';
import { useForumBoardContext } from './ForumBoardContext';
import { ForumThreadDetail } from './ForumThreadDetail';
import type { ForumPost } from './types';
import * as theme from './forumTheme.css';
/** Thread detail in the main pane; post list lives in ForumFeedSidebar. */
export function ForumBoardDetail() {
const mx = useMatrixClient();
const navigate = useNavigate();
const compact = useCompactNav();
const [optimisticPost, setOptimisticPost] = useState<ForumPost | null>(null);
const {
forumSpace,
query,
error,
selectedPostId,
activeTopicRoomId,
threads,
recordThreadReply,
} = useForumBoardContext();
// Compact: /feed/ without a selected post has no detail content — return to the list
// so an empty detail layer cannot cover the forum sidebar.
useEffect(() => {
if (!compact || selectedPostId) return;
navigate(getSpacePath(getCanonicalAliasOrRoomId(mx, forumSpace.roomId)), { replace: true });
}, [compact, selectedPostId, navigate, mx, forumSpace.roomId]);
const activeTopicRoom = activeTopicRoomId ? mx.getRoom(activeTopicRoomId) : null;
const topicSelected = Boolean(query.topic);
const setActiveThreadId = useSetAtom(
activeThreadIdAtomFamily(activeTopicRoomId ?? forumSpace.roomId)
);
useEffect(() => {
if (selectedPostId && activeTopicRoomId) {
setActiveThreadId(selectedPostId);
return () => setActiveThreadId(undefined);
}
setActiveThreadId(undefined);
return undefined;
}, [selectedPostId, activeTopicRoomId, setActiveThreadId]);
useEffect(() => {
if (optimisticPost && optimisticPost.eventId !== selectedPostId) {
setOptimisticPost(null);
}
}, [optimisticPost, selectedPostId]);
const selectedThread = useMemo(
() => threads.find((t) => t.eventId === selectedPostId),
[threads, selectedPostId]
);
const handleReplySent = useCallback(
(_reply: ForumPost) => {
if (selectedPostId) {
recordThreadReply(selectedPostId);
}
},
[recordThreadReply, selectedPostId]
);
const showThread = Boolean(selectedPostId && activeTopicRoom);
const threadInitialPost =
optimisticPost?.eventId === selectedPostId ? optimisticPost : null;
const detailDropRef = useRef<HTMLDivElement>(null);
return (
<Page className={classNames(ContainerColor({ variant: 'Background' }), theme.ForumAppRoot)}>
<PageContent className={theme.ForumPageContent}>
{error && (
<Text as="p" style={{ color: color.Critical.Main, padding: '0.5rem 1rem' }}>
{error}
</Text>
)}
<aside className={theme.ForumDetailPane}>
{showThread && activeTopicRoom ? (
<div ref={detailDropRef} className={theme.ForumTopicPostDetailThreadLayout}>
<ForumThreadDetail
roomId={activeTopicRoom.roomId}
rootEventId={selectedPostId!}
titleHint={selectedThread?.title}
initialPost={threadInitialPost}
fileDropContainerRef={detailDropRef}
onReplySent={handleReplySent}
/>
</div>
) : (
<Scroll
id="topicPostDetail"
className={theme.ForumTopicPostDetailScroll}
variant="Surface"
direction="Vertical"
size="300"
hideTrack
visibility="Hover"
>
<div className={theme.ForumTopicPostDetailInner}>
<Box
className={theme.ForumEmptyDetail}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="300"
>
<Icon src={Icons.Thread} size="600" />
<Text size="T300" priority="300">
{topicSelected
? 'Select a post to read the thread'
: 'Pick a category and topic, then choose a post'}
</Text>
</Box>
</div>
</Scroll>
)}
</aside>
</PageContent>
</Page>
);
}

View File

@@ -0,0 +1,431 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useCompactNav } from '../../hooks/useCompactNav';
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
import { getSpaceFeedPath, getSpaceLobbyPath, getSpacePath } from '../../pages/pathUtils';
import { createRoomModalAtom } from '../../state/createRoomModal';
import { createSpaceModalAtom } from '../../state/createSpaceModal';
import { StateEvent } from '../../../types/matrix/room';
import {
buildForumSections,
buildTopicThreads,
buildThreadSummaries,
} from './forumTopicHelpers';
import { applyTopicStatusSort } from './topicSearch';
import {
createForumPostFromPayload,
listForumTopics,
listTopicFeedPosts,
loadAggregatedTopicFeed,
} from './forumFeed';
import type {
ForumBoardQuery,
ForumPost,
ForumPublishedPost,
ForumSection,
ForumThreadSummary,
} from './types';
import { useForumRoomLiveUpdates } from './useForumRoomLiveUpdates';
export type ForumBoardScope = {
forumSpaceId: string;
/** When set, only show posts from this topic room. */
topicRoomId?: string;
};
function scopesFromSelection(
sections: ForumSection[],
category?: string,
topic?: string
): Array<{ roomId: string; sectionTitle?: string; topicName?: string }> {
if (topic) {
for (const section of sections) {
const match = section.topics.find((t) => t.roomId === topic);
if (match) {
return [{ roomId: match.roomId, sectionTitle: section.title, topicName: match.name }];
}
}
return [{ roomId: topic }];
}
if (category) {
const section = sections.find((s) => s.title === category);
if (!section) return [];
return section.topics.map((t) => ({
roomId: t.roomId,
sectionTitle: section.title,
topicName: t.name,
}));
}
return sections.flatMap((section) =>
section.topics.map((t) => ({
roomId: t.roomId,
sectionTitle: section.title,
topicName: t.name,
}))
);
}
export function useForumBoard(scope: ForumBoardScope, forumSpace: Room) {
const mx = useMatrixClient();
const location = useLocation();
const navigate = useNavigate();
const selectedRoomId = useSelectedRoom();
const compact = useCompactNav();
const searchParams = useMemo(
() => new URLSearchParams(location.search),
[location.search]
);
const filterQuery: ForumBoardQuery = useMemo(
() => ({
category: searchParams.get('category') || undefined,
topic: searchParams.get('topic') || undefined,
q: searchParams.get('q') || undefined,
status: (searchParams.get('status') as ForumBoardQuery['status']) || 'all',
sort: (searchParams.get('sort') as ForumBoardQuery['sort']) || 'hot',
}),
[searchParams]
);
const query: ForumBoardQuery = useMemo(
() => ({
...filterQuery,
post: searchParams.get('post') || undefined,
postRoom: searchParams.get('postRoom') || undefined,
}),
[filterQuery, searchParams]
);
const [sections, setSections] = useState<ForumSection[]>([]);
const [threads, setThreads] = useState<ForumThreadSummary[]>([]);
const [loadingSections, setLoadingSections] = useState(true);
const [loadingThreads, setLoadingThreads] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [postsNextBatch, setPostsNextBatch] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const applyForumSearchParams = useCallback(
(mutate: (next: URLSearchParams) => void) => {
const next = new URLSearchParams(location.search);
mutate(next);
const search = next.toString();
const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, forumSpace.roomId);
const onTopicRoomRoute =
Boolean(selectedRoomId) && selectedRoomId !== forumSpace.roomId;
// Compact master view only mounts the post list on /:spaceId/. Opening a post
// must switch to /feed/ so ForumFeedPage mounts as the detail pane.
let pathname = location.pathname;
if (onTopicRoomRoute) {
pathname = getSpaceLobbyPath(spaceIdOrAlias);
} else if (next.get('post')) {
pathname = getSpaceFeedPath(spaceIdOrAlias);
} else if (compact) {
pathname = getSpacePath(spaceIdOrAlias);
}
navigate(
{
pathname,
search: search ? `?${search}` : '',
},
{ replace: true }
);
},
[
location.pathname,
location.search,
navigate,
mx,
forumSpace.roomId,
selectedRoomId,
compact,
]
);
const setQueryParam = useCallback(
(key: string, value: string | null) => {
applyForumSearchParams((next) => {
if (!value) next.delete(key);
else next.set(key, value);
if (key !== 'post') {
next.delete('post');
next.delete('postRoom');
}
});
},
[applyForumSearchParams]
);
const setCategory = useCallback(
(category: string | null) => {
applyForumSearchParams((next) => {
if (!category) next.delete('category');
else next.set('category', category);
next.delete('topic');
next.delete('post');
next.delete('postRoom');
});
},
[applyForumSearchParams]
);
const loadSections = useCallback(async () => {
setLoadingSections(true);
setError(null);
try {
const topics = await listForumTopics(mx, scope.forumSpaceId);
setSections(buildForumSections(topics));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load forum categories');
} finally {
setLoadingSections(false);
}
}, [mx, scope.forumSpaceId]);
const refreshThreads = useCallback(async (options?: { silent?: boolean }) => {
if (!options?.silent) setLoadingThreads(true);
setError(null);
try {
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
if (roomScopes.length === 0) {
setThreads([]);
return;
}
let posts;
if (roomScopes.length === 1) {
const result = await listTopicFeedPosts(mx, roomScopes[0].roomId);
posts = result.posts;
setPostsNextBatch(result.nextBatch);
const scopeMeta = roomScopes[0];
posts.forEach((post) => {
post.topicRoomId = scopeMeta.roomId;
if (scopeMeta.sectionTitle) post.sectionTitle = scopeMeta.sectionTitle;
if (scopeMeta.topicName) post.topicName = scopeMeta.topicName;
});
} else {
posts = await loadAggregatedTopicFeed(mx, roomScopes);
setPostsNextBatch(null);
}
setThreads(buildTopicThreads(posts, filterQuery));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load forum posts');
} finally {
if (!options?.silent) setLoadingThreads(false);
}
}, [mx, sections, filterQuery]);
useEffect(() => {
loadSections();
}, [loadSections]);
useEffect(() => {
const handleSpaceChild = (event: MatrixEvent) => {
if (event.getType() !== StateEvent.SpaceChild) return;
const roomId = event.getRoomId();
if (!roomId) return;
if (
roomId === forumSpace.roomId ||
sections.some((section) => section.spaceId === roomId)
) {
loadSections();
}
};
mx.on(RoomStateEvent.Events, handleSpaceChild);
return () => {
mx.removeListener(RoomStateEvent.Events, handleSpaceChild);
};
}, [mx, forumSpace.roomId, sections, loadSections]);
const createSpaceModal = useAtomValue(createSpaceModalAtom);
const createRoomModal = useAtomValue(createRoomModalAtom);
const prevCreateSpaceModal = useRef(createSpaceModal);
const prevCreateRoomModal = useRef(createRoomModal);
useEffect(() => {
if (prevCreateSpaceModal.current && !createSpaceModal) {
loadSections();
}
if (prevCreateRoomModal.current && !createRoomModal) {
loadSections();
}
prevCreateSpaceModal.current = createSpaceModal;
prevCreateRoomModal.current = createRoomModal;
}, [createSpaceModal, createRoomModal, loadSections]);
useEffect(() => {
if (loadingSections) return;
void refreshThreads();
}, [
loadingSections,
refreshThreads,
filterQuery.category,
filterQuery.topic,
filterQuery.q,
filterQuery.status,
filterQuery.sort,
]);
const scopedRooms = useMemo(() => {
if (loadingSections) return [];
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
return roomScopes
.map((scope) => mx.getRoom(scope.roomId))
.filter((room): room is Room => room !== null);
}, [loadingSections, sections, filterQuery.category, filterQuery.topic, mx]);
useForumRoomLiveUpdates(scopedRooms, () => {
void refreshThreads({ silent: true });
});
const selectedPostId = query.post ?? null;
const selectedThread = threads.find((t) => t.eventId === selectedPostId);
const activeTopicRoomId =
query.postRoom || selectedThread?.topicRoomId || query.topic || scope.topicRoomId || null;
const selectPost = useCallback(
(eventId: string | null, topicRoomId?: string) => {
applyForumSearchParams((next) => {
if (!eventId) {
next.delete('post');
next.delete('postRoom');
} else {
next.set('post', eventId);
if (topicRoomId) next.set('postRoom', topicRoomId);
else next.delete('postRoom');
}
});
},
[applyForumSearchParams]
);
const insertThreadFromNewPost = useCallback(
(published: ForumPublishedPost): ForumPost | null => {
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
const inScope =
filterQuery.topic === published.topicRoomId ||
(!filterQuery.topic &&
(filterQuery.category
? roomScopes.some((s) => s.roomId === published.topicRoomId)
: true));
const scopeMeta = roomScopes.find((s) => s.roomId === published.topicRoomId);
const post = createForumPostFromPayload(
mx,
published.topicRoomId,
published.eventId,
{
title: published.title,
plainText: published.plainText,
formattedHtml: published.formattedHtml,
},
{
topicRoomId: published.topicRoomId,
sectionTitle: scopeMeta?.sectionTitle,
topicName: scopeMeta?.topicName,
}
);
if (inScope) {
const [summary] = buildThreadSummaries([post]);
if (summary) {
setThreads((prev) => {
const without = prev.filter((t) => t.eventId !== summary.eventId);
const merged = [summary, ...without];
return applyTopicStatusSort(merged, filterQuery.status || 'all', filterQuery.sort || 'hot');
});
}
}
return post;
},
[mx, sections, filterQuery]
);
const recordThreadReply = useCallback((rootEventId: string) => {
setThreads((prev) =>
prev.map((t) =>
t.eventId === rootEventId
? {
...t,
totalReplies: t.totalReplies + 1,
lastActivityTs: Date.now(),
}
: t
)
);
}, []);
const loadMorePosts = useCallback(async () => {
if (!postsNextBatch || loadingMore) return;
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
if (roomScopes.length !== 1) return;
setLoadingMore(true);
setError(null);
try {
const scopeMeta = roomScopes[0];
const result = await listTopicFeedPosts(mx, scopeMeta.roomId, {
from: postsNextBatch,
minRoots: 1,
});
setPostsNextBatch(result.nextBatch);
result.posts.forEach((post) => {
post.topicRoomId = scopeMeta.roomId;
if (scopeMeta.sectionTitle) post.sectionTitle = scopeMeta.sectionTitle;
if (scopeMeta.topicName) post.topicName = scopeMeta.topicName;
});
setThreads((prev) => {
const existingIds = new Set(prev.map((t) => t.eventId));
const added = buildTopicThreads(result.posts, filterQuery).filter(
(t) => !existingIds.has(t.eventId)
);
const merged = [...prev, ...added];
const sort = filterQuery.sort || 'hot';
merged.sort((a, b) => {
if (sort === 'new') return b.timestamp - a.timestamp;
if (sort === 'top') return b.totalReplies - a.totalReplies;
const hotA = a.totalReplies * 4 + Math.floor((a.lastActivityTs - a.timestamp) / 3600000);
const hotB = b.totalReplies * 4 + Math.floor((b.lastActivityTs - b.timestamp) / 3600000);
return hotB - hotA;
});
return merged;
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load older posts');
} finally {
setLoadingMore(false);
}
}, [postsNextBatch, loadingMore, sections, filterQuery, mx]);
return {
forumSpace,
sections,
threads,
query,
loadingSections,
loadingThreads,
error,
selectedPostId,
selectedThread,
activeTopicRoomId,
setQueryParam,
setCategory,
selectPost,
refreshThreads,
insertThreadFromNewPost,
recordThreadReply,
loadMorePosts,
hasMorePosts: Boolean(postsNextBatch),
loadingMore,
};
}

View File

@@ -0,0 +1,185 @@
import React, { useCallback, useRef } from 'react';
import { Box, Text, config } from 'folds';
import { EventType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
import { isKeyHotkey } from 'is-hotkey';
import { useAtomValue } from 'jotai';
import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useEditor } from '../../components/editor';
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
import { RoomTimeline } from './RoomTimeline';
import { RoomViewTyping } from './RoomViewTyping';
import { RoomTombstone } from './RoomTombstone';
import { RoomInput } from './RoomInput';
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
import { Page } from '../../components/page';
import { RoomViewHeader } from './RoomViewHeader';
import { useKeyDown } from '../../hooks/useKeyDown';
import { editableActiveElement } from '../../utils/dom';
import { settingsAtom } from '../../state/settings';
import { useSetting } from '../../state/hooks/settings';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { activeThreadIdAtomFamily } from '../../state/activeThread';
import { ThreadView } from './ThreadView';
import { MobileSwipeToReplyLayer } from '../../components/mobile/MobileSwipeToReplyLayer';
import { useMobileKeyboardLayout } from '../../hooks/useMobileKeyboardLayout';
import * as composerCss from './room-composer-mobile.css';
const FN_KEYS_REGEX = /^F\d+$/;
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
const { code } = evt;
if (evt.metaKey || evt.altKey || evt.ctrlKey) {
return false;
}
if (FN_KEYS_REGEX.test(code)) return false;
if (
code.startsWith('OS') ||
code.startsWith('Meta') ||
code.startsWith('Shift') ||
code.startsWith('Alt') ||
code.startsWith('Control') ||
code.startsWith('Arrow') ||
code.startsWith('Page') ||
code.startsWith('End') ||
code.startsWith('Home') ||
code === 'Tab' ||
code === 'Space' ||
code === 'Enter' ||
code === 'NumLock' ||
code === 'ScrollLock'
) {
return false;
}
return true;
};
export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
const roomInputRef = useRef<HTMLDivElement>(null);
const roomViewRef = useRef<HTMLDivElement>(null);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const { immersive } = useMobileKeyboardLayout();
const { roomId } = room;
const editor = useEditor();
const mx = useMatrixClient();
const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());
const activeThreadId = useAtomValue(activeThreadIdAtomFamily(roomId));
useKeyDown(
window,
useCallback(
(evt) => {
if (editableActiveElement()) return;
const portalContainer = document.getElementById('portalContainer');
if (portalContainer && portalContainer.children.length > 0) {
return;
}
if (shouldFocusMessageField(evt)) {
evt.preventDefault();
ReactEditor.focus(editor);
if (evt.key.length === 1) {
editor.insertText(evt.key);
}
} else if (isKeyHotkey('mod+v', evt)) {
ReactEditor.focus(editor);
}
},
[editor]
)
);
const flushComposer = immersive;
const composerPadding = flushComposer ? '0' : `0 ${config.space.S400}`;
const showHeader = !immersive;
return (
<Page ref={roomViewRef}>
{showHeader && <RoomViewHeader />}
{activeThreadId ? (
<ThreadView room={room} threadRootId={activeThreadId} />
) : (
<>
<MobileSwipeToReplyLayer room={room} editor={editor}>
<Box grow="Yes" direction="Column">
<RoomTimeline
key={roomId}
room={room}
eventId={eventId}
roomInputRef={roomInputRef}
editor={editor}
/>
<RoomViewTyping room={room} />
</Box>
</MobileSwipeToReplyLayer>
<Box
shrink="No"
direction="Column"
data-disable-swipe-back="true"
data-composer-flush={flushComposer ? 'true' : undefined}
className={composerCss.ComposerDock}
>
{immersive && !hideActivity && (
<div className={composerCss.FollowingFloat}>
<div className={composerCss.FollowingFloatHit}>
<RoomViewFollowing room={room} />
</div>
</div>
)}
<div style={{ padding: composerPadding }}>
{tombstoneEvent ? (
<RoomTombstone
roomId={roomId}
body={tombstoneEvent.getContent().body}
replacementRoomId={tombstoneEvent.getContent().replacement_room}
/>
) : (
<>
{canMessage && (
<RoomInput
room={room}
editor={editor}
roomId={roomId}
fileDropContainerRef={roomViewRef}
ref={roomInputRef}
/>
)}
{!canMessage && (
<RoomInputPlaceholder
style={{ padding: config.space.S200 }}
alignItems="Center"
justifyContent="Center"
>
<Text align="Center">You do not have permission to post in this room</Text>
</RoomInputPlaceholder>
)}
</>
)}
</div>
{!immersive &&
(hideActivity ? (
<RoomViewFollowingPlaceholder />
) : (
<RoomViewFollowing room={room} />
))}
</Box>
</>
)}
</Page>
);
}

View File

@@ -0,0 +1,49 @@
import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds';
export const RoomViewFollowingPlaceholder = style([
DefaultReset,
{
height: toRem(28),
},
]);
export const RoomViewFollowing = recipe({
base: [
DefaultReset,
{
minHeight: toRem(28),
padding: `0 ${config.space.S400}`,
width: '100%',
backgroundColor: color.Surface.Container,
color: color.Surface.OnContainer,
outline: 'none',
selectors: {
// Compact floating chip while the keyboard is open.
'[data-composer-flush="true"] &': {
minHeight: 0,
width: 'auto',
maxWidth: '100%',
padding: `${config.space.S100} ${config.space.S200}`,
backgroundColor: 'transparent',
},
},
},
],
variants: {
clickable: {
true: {
cursor: 'pointer',
selectors: {
'&:hover, &:focus-visible': {
color: color.Primary.Main,
},
'&:active': {
color: color.Primary.Main,
},
},
},
},
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
import { RelationType, Room } from 'matrix-js-sdk';
import { Editor } from 'slate';
import { ReactEditor } from 'slate-react';
import { getEditedEvent } from '../../utils/room';
import { IReplyDraft } from '../../state/room/roomInputDrafts';
export const startReplyToEvent = (
room: Room,
eventId: string,
setReplyDraft: (draft: IReplyDraft | undefined) => void,
editor?: Editor,
startThread = false
): boolean => {
const replyEvt = room.findEventById(eventId);
if (!replyEvt || replyEvt.isRedacted()) return false;
const editedReply = getEditedEvent(eventId, replyEvt, room.getUnfilteredTimelineSet());
const content = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
const { body, formatted_body: formattedBody } = content;
const senderId = replyEvt.getSender();
if (!senderId || typeof body !== 'string') return false;
const relation = startThread
? { rel_type: RelationType.Thread, event_id: eventId }
: replyEvt.getWireContent()['m.relates_to'];
setReplyDraft({
userId: senderId,
eventId,
body,
formattedBody,
relation,
});
if (editor) {
setTimeout(() => ReactEditor.focus(editor), 100);
}
return true;
};

View File

@@ -0,0 +1,63 @@
import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';
import { DefaultReset, color, config } from 'folds';
export const ComposerDock = style({
position: 'relative',
});
/** Read receipts float above the input so they don't reserve layout height. */
export const FollowingFloat = style({
position: 'absolute',
bottom: '100%',
left: 0,
right: 0,
zIndex: 2,
display: 'flex',
justifyContent: 'flex-end',
pointerEvents: 'none',
padding: `0 ${config.space.S200}`,
});
export const FollowingFloatHit = style({
pointerEvents: 'auto',
maxWidth: '100%',
});
export const RoomViewFollowingPlaceholder = style([
DefaultReset,
{
display: 'none',
height: 0,
},
]);
export const RoomViewFollowing = recipe({
base: [
DefaultReset,
{
minHeight: 0,
padding: `${config.space.S100} ${config.space.S200}`,
width: 'auto',
maxWidth: '100%',
backgroundColor: 'transparent',
color: color.Surface.OnContainer,
outline: 'none',
},
],
variants: {
clickable: {
true: {
cursor: 'pointer',
selectors: {
'&:hover, &:focus-visible': {
color: color.Primary.Main,
},
'&:active': {
color: color.Primary.Main,
},
},
},
},
},
});

View File

@@ -1,4 +1,4 @@
import React, { useCallback } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text, Switch, Button, color, Spinner } from 'folds'; import { Box, Text, Switch, Button, color, Spinner } from 'folds';
import { IPusherRequest } from 'matrix-js-sdk'; import { IPusherRequest } from 'matrix-js-sdk';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
@@ -11,6 +11,12 @@ import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { isCapacitorNative, requestSystemNotificationPermission } from '../../../utils/tauri'; import { isCapacitorNative, requestSystemNotificationPermission } from '../../../utils/tauri';
import {
isBackgroundSyncSupported,
getBackgroundSyncStatus,
requestResetPushRegistration,
triggerBackgroundSyncPing,
} from '../../../utils/backgroundSync';
function EmailNotification() { function EmailNotification() {
const mx = useMatrixClient(); const mx = useMatrixClient();
@@ -147,6 +153,7 @@ export function SystemNotification() {
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />} after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
/> />
</SequenceCard> </SequenceCard>
{isBackgroundSyncSupported() && <AndroidPushNotifications />}
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="SurfaceVariant"
@@ -158,3 +165,126 @@ export function SystemNotification() {
</Box> </Box>
); );
} }
type PushStatus = {
registered: boolean;
distributor: string;
endpoint: string;
};
/** Android-only section showing UnifiedPush registration status and controls. */
function AndroidPushNotifications() {
const [status, setStatus] = useState<PushStatus | undefined>(undefined);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
setLoading(true);
const s = await getBackgroundSyncStatus();
setStatus(
s
? {
registered: s.registered,
distributor: s.distributor || '',
endpoint: s.endpoint || '',
}
: undefined
);
setLoading(false);
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const [resetState, reset] = useAsyncCallback(
useCallback(async () => {
await requestResetPushRegistration();
await refresh();
}, [refresh])
);
const [pingState, ping] = useAsyncCallback(
useCallback(async () => {
await triggerBackgroundSyncPing('manual-test');
}, [])
);
const isBusy =
loading ||
resetState.status === AsyncStatus.Loading ||
pingState.status === AsyncStatus.Loading;
return (
<Box direction="Column" gap="100">
<Text size="L400">Android Push (UnifiedPush)</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Background Notifications"
description={
loading ? (
'Loading status…'
) : status === undefined ? (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
Failed to read push status.
</Text>
) : status.registered ? (
<>
<Text as="span" size="T200">
{`Distributor: ${status.distributor || 'unknown'}`}
</Text>
</>
) : (
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
Not registered. Tap &quot;Reset&quot; to choose a distributor app.
</Text>
)
}
after={loading ? <Spinner variant="Secondary" /> : undefined}
/>
<SettingTile
title="Change Distributor"
description="Re-open the UnifiedPush distributor selection dialog."
after={
<Button
size="300"
radii="300"
variant="Secondary"
disabled={isBusy}
onClick={() => void reset()}
>
{resetState.status === AsyncStatus.Loading ? (
<Spinner variant="Secondary" size="200" />
) : (
<Text size="B300">Reset</Text>
)}
</Button>
}
/>
<SettingTile
title="Test Notification"
description="Trigger a test push ping to verify the pipeline works."
after={
<Button
size="300"
radii="300"
variant="Secondary"
disabled={isBusy}
onClick={() => void ping()}
>
{pingState.status === AsyncStatus.Loading ? (
<Spinner variant="Secondary" size="200" />
) : (
<Text size="B300">Send Test</Text>
)}
</Button>
}
/>
</SequenceCard>
</Box>
);
}

View File

@@ -0,0 +1,149 @@
/**
* Re-exports the full @paarrot/plugin-manager API and creates the shared
* singleton PluginRegistry instance for this application.
*
* All other modules in this folder should import from here rather than
* directly from the package so the singleton is guaranteed to be shared.
*/
export {
PluginMarketplaceClient,
PluginMarketplaceManager,
PluginRegistry,
createPluginContext,
generateThemeCSS,
MemoryStorage,
} from '@paarrot/plugin-manager';
export type {
IPluginStorage,
IPluginEventClient,
PluginMarketplaceClientOptions,
PluginMarketplaceHostAdapter,
PluginMarketplaceInstalledRecord,
PluginMarketplaceManagerOptions,
PluginRegistryOptions,
PluginContextOptions,
PluginMetadata,
InstalledPlugin,
PluginIndex,
ThemeColorGroup,
ThemePaletteGroup,
PluginThemeColors,
PluginTheme,
CommandArg,
PluginCommand,
MessageInterceptor,
MessageContext,
CustomRenderer,
SettingDefinition,
SettingsSchema,
NotificationOptions,
PluginContext,
PluginSettingsSection,
UILocation,
UIButtonDefinition,
UIButtonPosition,
Plugin,
PluginLogEntry,
} from '@paarrot/plugin-manager';
export { PluginTab } from '@paarrot/plugin-manager';
import {
PluginMarketplaceClient,
PluginMarketplaceManager,
PluginRegistry,
} from '@paarrot/plugin-manager';
import { getPluginHost } from '../../../utils/pluginHost';
const PLUGIN_INDEX_URL =
import.meta.env.VITE_PLUGIN_INDEX_URL ||
`https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/index.json?cache_burst=${(Math.random()*256).toString()}`;
const PLUGIN_BASE_URL =
import.meta.env.VITE_PLUGIN_BASE_URL ||
'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/';
function injectThemeStyle(styleId: string, css: string): void {
let el = document.getElementById(styleId) as HTMLStyleElement | null;
if (!el) {
el = document.createElement('style');
el.id = styleId;
document.head.appendChild(el);
}
el.textContent = css;
}
function removeThemeStyle(styleId: string): void {
document.getElementById(styleId)?.remove();
}
/** Shared singleton registry for the entire application. */
export const pluginRegistry = new PluginRegistry({
storage: localStorage,
onThemeRegistered: (themeId, _className, css) =>
injectThemeStyle(`plugin-theme-${themeId}`, css),
onThemeUnregistered: (themeId) => removeThemeStyle(`plugin-theme-${themeId}`),
});
/** Shared marketplace client for remote plugin directory reads. */
export const pluginMarketplaceClient = new PluginMarketplaceClient({
indexUrl: PLUGIN_INDEX_URL,
baseUrl: PLUGIN_BASE_URL,
});
/**
* Shared marketplace manager for install state, directory fetch, and enable flags.
*/
export const pluginMarketplaceManager = new PluginMarketplaceManager({
client: pluginMarketplaceClient,
storage: localStorage,
registry: pluginRegistry,
host: {
listInstalledPlugins: async () => {
const host = getPluginHost();
if (!host) {
return [];
}
const result = await host.list();
if (!result.success || !result.data) {
throw new Error(result.error ?? 'Failed to list installed plugins');
}
return result.data.map((plugin) => ({
id: plugin.id,
name: plugin.name,
version: plugin.version,
description: plugin.description,
author: plugin.author,
repository: plugin.repository,
thumbnail: plugin.thumbnail,
homepage: plugin.homepage,
tags: plugin.tags,
installedDate: plugin.installedDate,
}));
},
installPlugin: async (plugin) => {
const host = getPluginHost();
if (!host) {
return;
}
const result = await host.download(plugin.id, plugin.downloadUrl, plugin.name);
if (!result.success) {
throw new Error(result.error ?? 'Failed to install plugin');
}
},
uninstallPlugin: async (pluginId) => {
const host = getPluginHost();
if (!host) {
return;
}
const result = await host.uninstall(pluginId);
if (!result.success) {
throw new Error(result.error ?? 'Failed to uninstall plugin');
}
},
},
});

View File

@@ -0,0 +1,190 @@
import React, { useEffect } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { Plugin, createPluginContext } from '@paarrot/plugin-manager';
import { pluginMarketplaceManager, pluginRegistry } from './PluginAPI';
import { sendNotification } from '../../../utils/tauri';
import { getPluginHost } from '../../../utils/pluginHost';
import { dispatchPluginButtonsChanged } from './PluginButtonSlot';
interface PluginLoaderProps {
matrixClient: MatrixClient;
children: React.ReactNode;
}
/**
* Component that loads and initializes enabled plugins.
* Should be placed high in the component tree after MatrixClient is available.
*/
export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
console.log('[PluginLoader] Component rendering, matrixClient:', !!matrixClient);
useEffect(() => {
const loadPlugins = async () => {
try {
const pluginHost = getPluginHost();
console.log('[PluginLoader] Loading plugins...');
console.log('[PluginLoader] plugin host available:', !!pluginHost);
if (!pluginHost) {
console.log('[PluginLoader] Running in web mode, plugins not supported');
return;
}
const pluginsToLoad = await pluginMarketplaceManager.listEnabledPlugins();
console.log('[PluginLoader] Plugins to load:', pluginsToLoad.length);
console.log('[PluginLoader] Plugin list:', pluginsToLoad.map((p) => p.id));
for (const installedPlugin of pluginsToLoad) {
try {
console.log(`[PluginLoader] Loading plugin: ${installedPlugin.id}`);
const pluginModule = await loadPluginModule(installedPlugin.id);
const plugin = normalisePlugin(pluginModule);
if (!plugin || typeof plugin !== 'object') {
console.error(
`[PluginLoader] Plugin ${installedPlugin.id} does not export a valid plugin object`
);
continue;
}
if (typeof plugin.onLoad !== 'function') {
console.error(
`[PluginLoader] Plugin ${installedPlugin.id} does not have an onLoad function`
);
continue;
}
const context = createPluginContext(
{
pluginId: installedPlugin.id,
eventClient: matrixClient as any,
onNotify: async (opts) => {
await sendNotification({
title: opts.title,
body: opts.body,
path: undefined,
});
},
},
pluginRegistry
);
const compatContext = Object.assign(context as Record<string, unknown>, {
matrixClient,
matrix: {
on: (eventType: string, handler: (...args: unknown[]) => void) =>
matrixClient.on(eventType as any, handler as any),
off: (eventType: string, handler: (...args: unknown[]) => void) =>
matrixClient.off(eventType as any, handler as any),
},
React,
});
console.log('[PluginLoader] compatContext.React:', !!compatContext.React);
console.log('[PluginLoader] compatContext.ui:', !!compatContext.ui);
console.log('[PluginLoader] About to register plugin and call onLoad...');
pluginRegistry.registerPlugin(installedPlugin.id, plugin, context);
await plugin.onLoad(compatContext as any);
console.log(`[PluginLoader] Successfully loaded plugin: ${installedPlugin.id}`);
dispatchPluginButtonsChanged();
} catch (error) {
console.error(`[PluginLoader] Failed to load plugin ${installedPlugin.id}:`, error);
pluginRegistry.addLog(installedPlugin.id, 'error', ['Failed to load:', error]);
}
}
} catch (error) {
console.error('[PluginLoader] Error loading plugins:', error);
}
};
loadPlugins();
return () => {
console.log('[PluginLoader] Cleaning up plugins');
pluginRegistry.clear();
dispatchPluginButtonsChanged();
};
}, [matrixClient]);
return <>{children}</>;
}
/**
* Reads and evaluates a plugin's JS module from the native filesystem host.
* Supports both CommonJS and ESM-style exports.
*/
async function loadPluginModule(pluginId: string): Promise<any> {
const pluginHost = getPluginHost();
if (!pluginHost) {
throw new Error('Plugins are only supported in the desktop or Android app');
}
const response = await pluginHost.readPluginCode(pluginId);
if (!response.success || !response.data) {
throw new Error(response.error ?? 'Failed to read plugin code');
}
const pluginExports: any = {};
const mod = { exports: pluginExports };
try {
// eslint-disable-next-line no-new-func
const pluginFunction = new Function('module', 'exports', response.data);
pluginFunction(mod, pluginExports);
} catch (error) {
if (!(error instanceof SyntaxError)) {
throw error;
}
const dataUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(response.data)}`;
const importedModule = await import(/* @vite-ignore */ dataUrl);
return importedModule;
}
return mod.exports;
}
/**
* Normalizes raw plugin exports to current plugin interface.
* Supports native `onLoad` and legacy `activate` / `deactivate` plugins.
*/
function normalisePlugin(raw: Record<string, unknown>): Plugin {
if (typeof raw.onLoad === 'function') {
return raw as unknown as Plugin;
}
if (typeof raw.activate === 'function') {
const activate = raw.activate as (ctx: unknown) => void | Promise<void>;
const deactivate = raw.deactivate as ((ctx?: unknown) => void | Promise<void>) | undefined;
return {
name: typeof raw.name === 'string' ? raw.name : undefined,
version: typeof raw.version === 'string' ? raw.version : undefined,
onLoad: async (ctx) => {
const legacyCtx = Object.assign(Object.create(ctx as object), {
registerHook: (name: string) =>
ctx.log(`[compat] registerHook("${name}") - not supported`),
runHook: (name: string) => {
ctx.log(`[compat] runHook("${name}")`);
return Promise.resolve([]);
},
getConfig: (key: string) => ctx.settings.get(key),
on: (event: string, handler: (...args: unknown[]) => void) =>
ctx.events.on(event, handler),
});
await activate(legacyCtx);
},
onUnload: deactivate
? async () => {
await deactivate();
}
: undefined,
};
}
return raw as unknown as Plugin;
}

View File

@@ -0,0 +1,30 @@
import { matchPath, useLocation } from 'react-router-dom';
import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from '../pages/paths';
import { ScreenSize, useScreenSizeContext } from './useScreenSize';
/** Skinny window layout (≤750px): master-detail instead of 3-column desktop. */
export const useCompactNav = (): boolean => {
const screenSize = useScreenSizeContext();
return screenSize === ScreenSize.Mobile;
};
const LIST_ROUTE_PATHS = [HOME_PATH, DIRECT_PATH, SPACE_PATH, EXPLORE_PATH, INBOX_PATH] as const;
/**
* Routes where sidebar + channel list are shown together (no room/content pane).
* Uses matchPath on the current pathname so trailing-slash / encoding quirks
* after DM → space navigations still count as master list routes.
*/
export const useIsCompactListRoute = (): boolean => {
const { pathname } = useLocation();
return LIST_ROUTE_PATHS.some(
(path) => matchPath({ path, caseSensitive: true, end: true }, pathname) != null
);
};
export const useShowCompactMasterView = (): boolean => {
const compact = useCompactNav();
const isListRoute = useIsCompactListRoute();
return compact && isListRoute;
};

View File

@@ -0,0 +1,111 @@
import { useCallback, ClipboardEventHandler } from 'react';
import { getDataTransferFiles } from '../utils/dom';
import { readClipboardImage, isTauri, isLinux, isCapacitorNative } from '../utils/tauri';
const filesFromClipboardItems = (data: DataTransfer | null): File[] => {
if (!data?.items) return [];
const files: File[] = [];
for (let i = 0; i < data.items.length; i += 1) {
const item = data.items[i];
if (item.kind !== 'file') continue;
const file = item.getAsFile();
if (file) files.push(file);
}
return files;
};
const filesFromHtmlImage = (data: DataTransfer | null): File[] => {
const html = data?.getData('text/html');
if (!html) return [];
const match = html.match(/<img[^>]+src=["'](data:image\/[a-zA-Z+]+;base64,[^"']+)["']/i);
if (!match?.[1]) return [];
try {
const dataUrl = match[1];
const [meta, base64] = dataUrl.split(',');
const mime = meta.match(/data:(image\/[a-zA-Z+]+);base64/i)?.[1] ?? 'image/png';
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
const ext = mime.split('/')[1]?.replace('+xml', '') || 'png';
return [new File([bytes], `clipboard-image.${ext}`, { type: mime })];
} catch {
return [];
}
};
const getClipboardFiles = (data: DataTransfer | null): File[] => {
if (!data) return [];
const fromFiles = getDataTransferFiles(data);
if (fromFiles && fromFiles.length > 0) return fromFiles;
const fromItems = filesFromClipboardItems(data);
if (fromItems.length > 0) return fromItems;
return filesFromHtmlImage(data);
};
/** Async Clipboard API fallback (when paste event has no file payload). */
const filesFromClipboardApi = async (): Promise<File[]> => {
if (!navigator.clipboard?.read) return [];
try {
const items = await navigator.clipboard.read();
const files: File[] = [];
for (const item of items) {
for (const type of item.types) {
if (!type.startsWith('image/')) continue;
const blob = await item.getType(type);
const ext = type.split('/')[1]?.replace('+xml', '') || 'png';
files.push(new File([blob], `clipboard-image.${ext}`, { type }));
}
}
return files;
} catch {
return [];
}
};
/**
* Paste handler for RoomInput. Supports clipboard images via files, DataTransferItem
* list, HTML data-URL fallbacks, and Clipboard API (common on mobile WebViews).
* On Capacitor Android, native OnReceiveContent also routes images into the share pipeline.
*/
export const useFilePasteHandler = (onPaste: (file: File[]) => void): ClipboardEventHandler =>
useCallback(
async (evt) => {
const files = getClipboardFiles(evt.clipboardData);
if (files.length > 0) {
evt.preventDefault();
evt.stopPropagation();
onPaste(files);
return;
}
// On Linux with Tauri, browser clipboard API doesn't work for images.
if (isTauri() && isLinux()) {
const clipboardImage = await readClipboardImage();
if (clipboardImage) {
evt.preventDefault();
evt.stopPropagation();
onPaste([clipboardImage]);
return;
}
}
// Mobile WebView / Capacitor: paste events often omit file payloads.
if (isCapacitorNative()) {
const apiFiles = await filesFromClipboardApi();
if (apiFiles.length > 0) {
evt.preventDefault();
evt.stopPropagation();
onPaste(apiFiles);
}
}
},
[onPaste]
);

View File

@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { isCapacitorNative } from '../utils/tauri';
const KEYBOARD_HEIGHT_THRESHOLD_PX = 120;
const IMMERSIVE_CLASS = 'immersive-landscape-keyboard';
const getIsLandscape = (): boolean => {
if (typeof window === 'undefined') return false;
if (window.matchMedia?.('(orientation: landscape)').matches) return true;
return window.innerWidth > window.innerHeight;
};
const getKeyboardOpen = (): boolean => {
if (typeof window === 'undefined') return false;
const viewport = window.visualViewport;
if (viewport) {
const obscured = window.innerHeight - viewport.height - viewport.offsetTop;
if (obscured > KEYBOARD_HEIGHT_THRESHOLD_PX) return true;
}
const active = document.activeElement;
if (!(active instanceof HTMLElement)) return false;
if (active.isContentEditable) return true;
const tag = active.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA';
};
const setNativeStatusBarHidden = async (hidden: boolean) => {
if (!isCapacitorNative()) return;
try {
const { StatusBar } = await import('@capacitor/status-bar');
if (hidden) {
await StatusBar.hide();
} else {
await StatusBar.show();
}
} catch {
// Plugin may be missing in some builds; CSS safe-area collapse still applies.
}
};
export type MobileKeyboardLayout = {
isLandscape: boolean;
keyboardOpen: boolean;
/** Landscape + keyboard: compact immersive room chrome. */
immersive: boolean;
};
/**
* Landscape + soft-keyboard layout signals for compact room chrome.
* Uses visualViewport when available (Capacitor/Android WebView).
*/
export const useMobileKeyboardLayout = (): MobileKeyboardLayout => {
const [isLandscape, setIsLandscape] = useState(getIsLandscape);
const [keyboardOpen, setKeyboardOpen] = useState(getKeyboardOpen);
const immersive = isLandscape && keyboardOpen;
useEffect(() => {
const sync = () => {
setIsLandscape(getIsLandscape());
setKeyboardOpen(getKeyboardOpen());
};
sync();
const orientationQuery = window.matchMedia?.('(orientation: landscape)');
orientationQuery?.addEventListener?.('change', sync);
window.addEventListener('resize', sync);
window.visualViewport?.addEventListener('resize', sync);
window.visualViewport?.addEventListener('scroll', sync);
window.addEventListener('focusin', sync);
window.addEventListener('focusout', sync);
return () => {
orientationQuery?.removeEventListener?.('change', sync);
window.removeEventListener('resize', sync);
window.visualViewport?.removeEventListener('resize', sync);
window.visualViewport?.removeEventListener('scroll', sync);
window.removeEventListener('focusin', sync);
window.removeEventListener('focusout', sync);
};
}, []);
useEffect(() => {
document.documentElement.classList.toggle(IMMERSIVE_CLASS, immersive);
void setNativeStatusBarHidden(immersive);
return () => {
document.documentElement.classList.remove(IMMERSIVE_CLASS);
void setNativeStatusBarHidden(false);
};
}, [immersive]);
return { isLandscape, keyboardOpen, immersive };
};

View File

@@ -0,0 +1,57 @@
import { useCallback, useEffect } from 'react';
import { useLocation, useNavigate, NavigateOptions } from 'react-router-dom';
import { supportsViewTransitions, withViewTransition } from '../utils/viewTransitions';
export const useViewTransitions = () => {
const location = useLocation();
useEffect(() => {
if (!supportsViewTransitions()) {
return;
}
document.documentElement.classList.add('view-transitions-enabled');
return () => {
document.documentElement.classList.remove('view-transitions-enabled');
};
}, []);
useEffect(() => {
// Route changed
}, [location]);
return {
supportsViewTransitions: supportsViewTransitions(),
};
};
/**
* Navigate helper. On Capacitor / coarse-pointer, View Transitions are disabled
* so DM ↔ space switches cannot leave a stuck hit-blocking overlay.
*/
export const useNavigateWithTransition = () => {
const navigate = useNavigate();
return useCallback(
(to: string | number, options?: NavigateOptions) => {
if (!supportsViewTransitions()) {
if (typeof to === 'number') {
navigate(to);
} else {
navigate(to, options);
}
return;
}
withViewTransition(() => {
if (typeof to === 'number') {
navigate(to);
} else {
navigate(to, options);
}
});
},
[navigate]
);
};

View File

@@ -0,0 +1,20 @@
import { ReactNode } from 'react';
type MobileFriendlyClientNavProps = {
children: ReactNode;
};
/** Server sidebar stays mounted on compact detail routes for swipe-back reveal. */
export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavProps) {
return children;
}
type MobileFriendlyPageNavProps = {
path: string;
children: ReactNode;
};
/** Channel list stays mounted on compact detail routes for swipe-back reveal. */
export function MobileFriendlyPageNav({ path: _path, children }: MobileFriendlyPageNavProps) {
return children;
}

View File

@@ -0,0 +1,159 @@
import React, {
ChangeEventHandler,
FocusEventHandler,
KeyboardEventHandler,
MouseEventHandler,
useEffect,
useRef,
useState,
} from 'react';
import {
Header,
Icon,
IconButton,
Icons,
Input,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
config,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useDebounce } from '../../hooks/useDebounce';
import { stopPropagation } from '../../utils/keyboard';
export function ServerPicker({
server,
serverList,
allowCustomServer,
onServerChange,
}: {
server: string;
serverList: string[];
allowCustomServer?: boolean;
onServerChange: (server: string) => void;
}) {
const [serverMenuAnchor, setServerMenuAnchor] = useState<RectCords>();
const serverInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// Only sync input when server changes externally (e.g., from menu selection)
// and input is not focused to avoid cursor jumping during typing
if (
serverInputRef.current &&
serverInputRef.current.value !== server &&
document.activeElement !== serverInputRef.current
) {
serverInputRef.current.value = server;
}
}, [server]);
const debounceServerSelect = useDebounce(onServerChange, { wait: 700 });
const handleServerChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const inputServer = evt.target.value.trim();
if (inputServer) debounceServerSelect(inputServer);
};
const handleBlur: FocusEventHandler<HTMLInputElement> = (evt) => {
const inputServer = evt.target.value.trim();
if (inputServer && inputServer !== server) {
onServerChange(inputServer);
}
};
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
if (evt.key === 'ArrowDown') {
evt.preventDefault();
setServerMenuAnchor(undefined);
}
if (evt.key === 'Enter') {
evt.preventDefault();
const inputServer = evt.currentTarget.value.trim();
if (inputServer) onServerChange(inputServer);
}
};
const handleServerSelect: MouseEventHandler<HTMLButtonElement> = (evt) => {
const selectedServer = evt.currentTarget.getAttribute('data-server');
if (selectedServer) {
onServerChange(selectedServer);
}
setServerMenuAnchor(undefined);
};
const handleOpenServerMenu: MouseEventHandler<HTMLElement> = (evt) => {
const target = evt.currentTarget.parentElement ?? evt.currentTarget;
setServerMenuAnchor(target.getBoundingClientRect());
};
return (
<Input
ref={serverInputRef}
style={{ paddingRight: config.space.S200 }}
variant={allowCustomServer ? 'Background' : 'Surface'}
outlined
defaultValue={server}
onChange={handleServerChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
size="500"
readOnly={!allowCustomServer}
onClick={allowCustomServer ? undefined : handleOpenServerMenu}
after={
serverList.length === 0 || (serverList.length === 1 && !allowCustomServer) ? undefined : (
<PopOut
anchor={serverMenuAnchor}
position="Bottom"
align="End"
offset={4}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setServerMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
<Text size="L400">Homeserver List</Text>
</Header>
<div style={{ padding: config.space.S100, paddingTop: 0 }}>
{serverList?.map((serverName) => (
<MenuItem
key={serverName}
radii="300"
aria-pressed={serverName === server}
data-server={serverName}
onClick={handleServerSelect}
>
<Text>{serverName}</Text>
</MenuItem>
))}
</div>
</Menu>
</FocusTrap>
}
>
<IconButton
onClick={handleOpenServerMenu}
variant={allowCustomServer ? 'Background' : 'Surface'}
size="300"
aria-pressed={!!serverMenuAnchor}
radii="300"
>
<Icon src={Icons.ChevronBottom} />
</IconButton>
</PopOut>
)
}
/>
);
}

View File

@@ -0,0 +1,21 @@
import React, { ReactNode } from 'react';
import { Box } from 'folds';
import { DockedCallPanel } from '../../features/call/DockedCallPanel';
import { useMobileKeyboardLayout } from '../../hooks/useMobileKeyboardLayout';
type ClientLayoutProps = {
nav: ReactNode;
children: ReactNode;
};
export function ClientLayout({ nav, children }: ClientLayoutProps) {
const { immersive } = useMobileKeyboardLayout();
return (
<Box grow="Yes">
{!immersive && <Box shrink="No">{nav}</Box>}
<Box grow="Yes">{children}</Box>
<DockedCallPanel />
</Box>
);
}

View File

@@ -1,5 +1,5 @@
import { useAtomValue } from 'jotai'; import { useAtomValue, useStore } from 'jotai';
import React, { ReactNode, useCallback, useEffect, useRef } from 'react'; import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread'; import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
@@ -22,12 +22,13 @@ import {
guessPerfectParent, guessPerfectParent,
} from '../../utils/room'; } from '../../utils/room';
import { NotificationType, UnreadInfo } from '../../../types/matrix/room'; import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
import { getMxIdLocalPart, mxcUrlToHttp, getCanonicalAliasOrRoomId } from '../../utils/matrix'; import { getMxIdLocalPart, mxcUrlToHttp, getCanonicalAliasOrRoomId, encryptFile } from '../../utils/matrix';
import { mDirectAtom } from '../../state/mDirectList'; import { mDirectAtom } from '../../state/mDirectList';
import { roomToParentsAtom } from '../../state/room/roomToParents'; import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { ShareRoomPicker } from '../../features/ShareRoomPicker';
import { import {
isTauri, isTauri,
isElectron, isElectron,
@@ -41,6 +42,20 @@ import {
stopBackgroundSync, stopBackgroundSync,
setAppForegroundState, setAppForegroundState,
} from '../../utils/backgroundSync'; } from '../../utils/backgroundSync';
import {
TUploadItem,
roomIdToUploadItemsAtomFamily,
} from '../../state/room/roomInputDrafts';
import { fulfilledPromiseSettledResult } from '../../utils/common';
import { safeFile } from '../../utils/mimeTypes';
import {
AndroidSharePayload,
clearPendingAndroidShare,
getPendingAndroidShare,
isAndroidShareSupported,
listenForAndroidShares,
materializeSharedFile,
} from '../../utils/androidShare';
/** /**
* Applies the selected emoji style font to the document. * Applies the selected emoji style font to the document.
@@ -361,6 +376,14 @@ function MessageNotifications() {
return; return;
} }
// Match TitleBar / room bell settings: Default and Mentions rooms only notify on highlights.
const notificationType = getNotificationType(mx, room.roomId);
const isDm = mDirects.has(room.roomId);
const mentionsOnly =
notificationType === NotificationType.MentionsAndKeywords ||
(notificationType === NotificationType.Default && !isDm);
if (mentionsOnly && unreadInfo.highlight === 0) return;
if ( if (
showNotifications && showNotifications &&
((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted')) ((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
@@ -382,7 +405,6 @@ function MessageNotifications() {
messageBody = typeof content.body === 'string' ? content.body : undefined; messageBody = typeof content.body === 'string' ? content.body : undefined;
} }
const isDm = room.getJoinedMemberCount() === 2 && !room.isSpaceRoom();
notify({ notify({
roomName: room.name ?? 'Unknown', roomName: room.name ?? 'Unknown',
roomAvatar: avatarMxc roomAvatar: avatarMxc
@@ -413,6 +435,7 @@ function MessageNotifications() {
notify, notify,
selectedRoomId, selectedRoomId,
useAuthentication, useAuthentication,
mDirects,
]); ]);
return null; return null;
@@ -426,8 +449,13 @@ function MessageNotifications() {
*/ */
function BackgroundSyncSetup() { function BackgroundSyncSetup() {
const mx = useMatrixClient(); const mx = useMatrixClient();
// Try to make a simple fetch to verify component is rendering
try {
fetch('/_matrix/client/v3/sync', { method: 'HEAD' }).catch(() => {});
} catch {}
useEffect(() => { useEffect(() => {
console.log('BackgroundSyncSetup: Starting background sync for', mx.getUserId());
startBackgroundSync(mx); startBackgroundSync(mx);
const onVisibility = () => setAppForegroundState(!document.hidden); const onVisibility = () => setAppForegroundState(!document.hidden);
@@ -493,6 +521,244 @@ function TaskbarFlashStopper() {
return null; return null;
} }
function AndroidShareIntentHandler() {
const mx = useMatrixClient();
const store = useStore();
const navigate = useNavigate();
const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null);
const mDirects = useAtomValue(mDirectAtom);
const roomToParents = useAtomValue(roomToParentsAtom);
const selectedRoomId = useSelectedRoom();
const selectedRoomIdRef = useRef(selectedRoomId);
selectedRoomIdRef.current = selectedRoomId;
/** Queue shared files into the open room's composer upload board (Gboard / paste / share). */
const queueShareAsDrafts = useCallback(
async (share: AndroidSharePayload, roomId: string) => {
const room = mx.getRoom(roomId);
if (!room) return false;
if (share.files.length > 0) {
const originalFiles: File[] = [];
for (const sharedFile of share.files) {
originalFiles.push(await materializeSharedFile(sharedFile, share.receivedAt));
}
const safeFiles = originalFiles.map(safeFile);
const fileItems: TUploadItem[] = [];
if (room.hasEncryptionStateEvent()) {
const encryptFiles = fulfilledPromiseSettledResult(
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
);
encryptFiles.forEach((ef) =>
fileItems.push({
...ef,
metadata: { markedAsSpoiler: false },
})
);
} else {
safeFiles.forEach((f) =>
fileItems.push({
file: f,
originalFile: f,
encInfo: undefined,
metadata: { markedAsSpoiler: false },
})
);
}
store.set(roomIdToUploadItemsAtomFamily(roomId), {
type: 'PUT',
item: fileItems,
});
}
const nextParts = [share.subject?.trim(), share.text?.trim()].filter(
(value): value is string => !!value
);
if (nextParts.length > 0) {
await mx.sendMessage(roomId, {
msgtype: 'm.text' as const,
body: nextParts.join('\n'),
});
}
await clearPendingAndroidShare();
return true;
},
[mx, store]
);
const applyPendingShare = useCallback(
async (share: AndroidSharePayload, roomId: string) => {
const room = mx.getRoom(roomId);
if (!room) return false;
// Send text message if present
const nextParts = [share.subject?.trim(), share.text?.trim()].filter(
(value): value is string => !!value
);
if (nextParts.length > 0) {
const textContent = {
msgtype: 'm.text' as const,
body: nextParts.join('\n'),
};
await mx.sendMessage(roomId, textContent);
}
// Upload and send files if present
if (share.files.length > 0) {
for (const sharedFile of share.files) {
const originalFile = await materializeSharedFile(sharedFile, share.receivedAt);
let fileToUpload = originalFile;
let encInfo: any = undefined;
if (room.hasEncryptionStateEvent()) {
const encrypted = await encryptFile(originalFile);
fileToUpload = encrypted.file;
encInfo = encrypted.encInfo;
}
// Upload file
const uploadResult = await mx.uploadContent(fileToUpload);
const mxc = uploadResult?.content_uri;
if (!mxc) continue;
// Determine message type and send
const fileType = originalFile.type;
let msgtype = 'm.file' as const;
if (fileType.startsWith('image/')) msgtype = 'm.image' as const;
else if (fileType.startsWith('video/')) msgtype = 'm.video' as const;
else if (fileType.startsWith('audio/')) msgtype = 'm.audio' as const;
const fileContent: any = {
msgtype,
body: originalFile.name,
filename: originalFile.name,
info: {
mimetype: originalFile.type,
size: originalFile.size,
},
};
if (encInfo) {
fileContent.file = {
...encInfo,
url: mxc,
};
} else {
fileContent.url = mxc;
}
await mx.sendMessage(roomId, fileContent);
}
}
await clearPendingAndroidShare();
return true;
},
[mx]
);
const ingestShare = useCallback(
(share: AndroidSharePayload) => {
const roomId = selectedRoomIdRef.current;
// Already in a room: attach to composer (Gboard GIF / paste / in-app share).
if (roomId && share.files.length > 0) {
queueShareAsDrafts(share, roomId).catch((err) => {
console.error('[AndroidShare] Failed to queue share into room:', err);
setPendingShare(share);
});
return;
}
setPendingShare(share);
},
[queueShareAsDrafts]
);
const handlePickRoom = useCallback(
(roomId: string) => {
if (!pendingShare) return;
applyPendingShare(pendingShare, roomId)
.then((applied) => {
if (applied) {
setPendingShare(null);
// Navigate to the selected room
const isDirect = mDirects.has(roomId);
if (isDirect) {
navigate(getDirectRoomPath(roomId));
} else {
const parents = roomToParents.get(roomId);
const parent = parents && parents.length > 0 ? parents[0] : undefined;
if (parent) {
navigate(getSpaceRoomPath(parent, roomId));
} else {
navigate(getHomeRoomPath(roomId));
}
}
}
})
.catch((err) => {
console.error('[AndroidShare] Failed to apply share after pick:', err);
setPendingShare(null);
});
},
[applyPendingShare, pendingShare, navigate, mDirects, roomToParents]
);
const handleDismiss = useCallback(() => {
clearPendingAndroidShare().catch(() => {});
setPendingShare(null);
}, []);
useEffect(() => {
if (!isAndroidShareSupported()) return;
let mounted = true;
let listenerHandle: { remove: () => Promise<void> } | undefined;
getPendingAndroidShare()
.then((share) => {
if (mounted && share) {
ingestShare(share);
}
})
.catch((err) => {
console.error('[AndroidShare] Failed to get pending share:', err);
});
listenForAndroidShares((share) => {
if (mounted) {
ingestShare(share);
}
})
.then((handle) => {
listenerHandle = handle;
})
.catch((err) => {
console.error('[AndroidShare] Failed to listen for shares:', err);
});
return () => {
mounted = false;
void listenerHandle?.remove();
};
}, [ingestShare]);
if (!pendingShare) return null;
return (
<ShareRoomPicker
share={pendingShare}
onPick={handlePickRoom}
onDismiss={handleDismiss}
/>
);
}
type ClientNonUIFeaturesProps = { type ClientNonUIFeaturesProps = {
children: ReactNode; children: ReactNode;
}; };
@@ -508,6 +774,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<BackgroundSyncSetup /> <BackgroundSyncSetup />
<PaarrotAPIInitializer /> <PaarrotAPIInitializer />
<TaskbarFlashStopper /> <TaskbarFlashStopper />
<AndroidShareIntentHandler />
{children} {children}
</> </>
); );

View File

@@ -0,0 +1,73 @@
import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
/** Metadata for a file received from an Android share intent. */
export type AndroidSharedFile = {
path: string;
name: string;
mimeType: string;
size: number;
};
/** Payload persisted by the native Android share handler. */
export type AndroidSharePayload = {
text?: string;
subject?: string;
files: AndroidSharedFile[];
receivedAt: number;
};
interface AndroidShareHandlerPlugin {
/** Returns the last pending share payload, if one exists. */
getPendingShare(): Promise<{ share: AndroidSharePayload | null }>;
/** Clears the last pending share payload after it has been consumed. */
clearPendingShare(): Promise<void>;
addListener(
eventName: 'shareReceived',
listenerFunc: (payload: AndroidSharePayload) => void
): Promise<PluginListenerHandle>;
}
const AndroidShareHandler = registerPlugin<AndroidShareHandlerPlugin>('AndroidShareHandler');
/** Returns true when the Android share bridge is available. */
export const isAndroidShareSupported = (): boolean =>
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
/** Fetches the current pending native share payload. */
export const getPendingAndroidShare = async (): Promise<AndroidSharePayload | null> => {
if (!isAndroidShareSupported()) return null;
const result = await AndroidShareHandler.getPendingShare();
return result.share;
};
/** Clears the native pending share payload. */
export const clearPendingAndroidShare = async (): Promise<void> => {
if (!isAndroidShareSupported()) return;
await AndroidShareHandler.clearPendingShare();
};
/** Subscribes to new incoming native share payloads. */
export const listenForAndroidShares = async (
listener: (payload: AndroidSharePayload) => void
): Promise<PluginListenerHandle | undefined> => {
if (!isAndroidShareSupported()) return undefined;
return AndroidShareHandler.addListener('shareReceived', listener);
};
/** Converts a cached native shared file into a browser File for upload. */
export const materializeSharedFile = async (
sharedFile: AndroidSharedFile,
receivedAt: number
): Promise<File> => {
const fileUrl = Capacitor.convertFileSrc(sharedFile.path);
const response = await fetch(fileUrl);
if (!response.ok) {
throw new Error(`Failed to read shared file: ${sharedFile.name}`);
}
const blob = await response.blob();
return new File([blob], sharedFile.name, {
type: sharedFile.mimeType || blob.type,
lastModified: receivedAt,
});
};

View File

@@ -7,7 +7,7 @@ type UnifiedPushStatus = {
instance: string; instance: string;
registered: boolean; registered: boolean;
distributor: string; distributor: string;
distributors: string[]; distributors: string[] | string;
}; };
type UnifiedPushEndpointEvent = { type UnifiedPushEndpointEvent = {
@@ -36,6 +36,8 @@ interface MatrixBackgroundSyncPlugin {
}): Promise<void>; }): Promise<void>;
/** Trigger a one-shot fetch as if a push ping arrived. */ /** Trigger a one-shot fetch as if a push ping arrived. */
triggerPing(options: { reason?: string }): Promise<void>; triggerPing(options: { reason?: string }): Promise<void>;
/** Re-open distributor setup flow and retry registration. */
requestDistributorSetup(): Promise<{ success: boolean }>;
/** Stop any in-flight fetch, clear credentials, and unregister UnifiedPush. */ /** Stop any in-flight fetch, clear credentials, and unregister UnifiedPush. */
stop(): Promise<void>; stop(): Promise<void>;
/** /**
@@ -112,6 +114,24 @@ const clearStoredPusherState = (userId: string | null, deviceId: string | null):
window.localStorage.removeItem(getStoredPusherKey(userId, deviceId)); window.localStorage.removeItem(getStoredPusherKey(userId, deviceId));
}; };
const normalizeDistributors = (raw: UnifiedPushStatus['distributors']): string[] => {
if (Array.isArray(raw)) return raw;
if (typeof raw !== 'string') return [];
const trimmed = raw.trim();
if (!trimmed || trimmed === '[]') return [];
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
return trimmed
.slice(1, -1)
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
}
return [trimmed];
};
const resolveUnifiedPushGateway = async (endpoint: string): Promise<string> => { const resolveUnifiedPushGateway = async (endpoint: string): Promise<string> => {
try { try {
const discoveryUrl = new URL(endpoint); const discoveryUrl = new URL(endpoint);
@@ -143,31 +163,52 @@ class AndroidUnifiedPushManager {
private listenersReady = false; private listenersReady = false;
private distributorSetupAttempted = false;
private distributorPromptShown = false;
/** Start native UnifiedPush registration and synchronize the Matrix pusher. */ /** Start native UnifiedPush registration and synchronize the Matrix pusher. */
async start(mx: MatrixClient): Promise<void> { async start(mx: MatrixClient): Promise<void> {
if (!isBackgroundSyncSupported()) return; console.log('[BackgroundSync] start() called, platform:', Capacitor.getPlatform(), 'isNative:', Capacitor.isNativePlatform());
if (!isBackgroundSyncSupported()) {
console.log('[BackgroundSync] Background sync not supported on this platform');
return;
}
const homeserverUrl = mx.getHomeserverUrl(); const homeserverUrl = mx.getHomeserverUrl();
const accessToken = mx.getAccessToken(); const accessToken = mx.getAccessToken();
const userId = mx.getUserId(); const userId = mx.getUserId();
const deviceId = mx.getDeviceId(); const deviceId = mx.getDeviceId();
console.log('[BackgroundSync] Credentials check:', { userId, deviceId, hasToken: !!accessToken, hasUrl: !!homeserverUrl });
if (!homeserverUrl || !accessToken || !userId) { if (!homeserverUrl || !accessToken || !userId) {
console.warn('[BackgroundSync] Missing credentials, not starting'); console.warn('[BackgroundSync] Missing credentials, not starting');
return; return;
} }
this.client = mx; this.client = mx;
this.distributorSetupAttempted = false;
this.distributorPromptShown = false;
await this.ensureListeners(); await this.ensureListeners();
await MatrixBackgroundSync.start({ try {
homeserverUrl, console.log('[BackgroundSync] Calling plugin.start()...');
accessToken, await MatrixBackgroundSync.start({
userId, homeserverUrl,
deviceId: deviceId ?? '', accessToken,
}); userId,
deviceId: deviceId ?? '',
});
console.log('[BackgroundSync] plugin.start() completed');
} catch (err) {
console.error('[BackgroundSync] plugin.start() failed:', err);
throw err;
}
await this.syncExistingEndpoint(); await this.syncExistingEndpoint();
await this.ensureDistributorPromptFromStatus();
console.log('[BackgroundSync] UnifiedPush registration requested'); console.log('[BackgroundSync] UnifiedPush registration requested');
} }
@@ -227,6 +268,67 @@ class AndroidUnifiedPushManager {
/** Log native registration failures so the missing distributor path is visible. */ /** Log native registration failures so the missing distributor path is visible. */
private handleRegistrationFailed(event: UnifiedPushRegistrationFailedEvent): void { private handleRegistrationFailed(event: UnifiedPushRegistrationFailedEvent): void {
console.warn('[BackgroundSync] UnifiedPush registration failed:', event.reason); console.warn('[BackgroundSync] UnifiedPush registration failed:', event.reason);
if (event.reason !== 'ACTION_REQUIRED' || this.distributorSetupAttempted) {
return;
}
this.distributorSetupAttempted = true;
void this.tryRequestDistributorSetup();
}
/** Re-opens distributor selection once after ACTION_REQUIRED and logs actionable status. */
private async tryRequestDistributorSetup(): Promise<void> {
try {
const setupResult = await MatrixBackgroundSync.requestDistributorSetup();
console.warn('[BackgroundSync] requestDistributorSetup result:', setupResult);
const status = await this.safeGetStatus();
console.warn('[BackgroundSync] UnifiedPush status after setup attempt:', status);
const distributors = normalizeDistributors(status?.distributors ?? []);
if (distributors.length === 0) {
console.error(
'[BackgroundSync] No UnifiedPush distributor installed. Install one (for example ntfy) to enable Android background notifications.'
);
this.showNoDistributorPrompt();
}
} catch (err) {
console.warn('[BackgroundSync] requestDistributorSetup failed:', err);
}
}
/** Fallback check so users still get prompted even when ACTION_REQUIRED event is missed. */
private async ensureDistributorPromptFromStatus(): Promise<void> {
const status = await this.safeGetStatus();
if (!status) return;
const distributors = normalizeDistributors(status.distributors);
if (!status.registered && distributors.length === 0) {
console.error(
'[BackgroundSync] No UnifiedPush distributor installed. Install one (for example ntfy) to enable Android background notifications.'
);
this.showNoDistributorPrompt();
}
}
/** Show a one-time actionable prompt when no distributor app is installed. */
private showNoDistributorPrompt(): void {
if (this.distributorPromptShown) return;
this.distributorPromptShown = true;
const message =
'Android background notifications need a UnifiedPush distributor app. Install one (for example ntfy), then reopen Paarrot.';
const docsUrl = 'https://unifiedpush.org/users/distributors/';
if (typeof window === 'undefined') return;
try {
const openDocs = window.confirm(`${message}\n\nOpen distributor list now?`);
if (openDocs) {
window.open(docsUrl, '_blank', 'noopener,noreferrer');
}
} catch {
window.alert(message);
}
} }
/** Install plugin listeners once for the active Matrix client. */ /** Install plugin listeners once for the active Matrix client. */
@@ -235,7 +337,9 @@ class AndroidUnifiedPushManager {
this.listenerHandles = [ this.listenerHandles = [
await MatrixBackgroundSync.addListener('unifiedPushNewEndpoint', (event) => { await MatrixBackgroundSync.addListener('unifiedPushNewEndpoint', (event) => {
void this.handleNewEndpoint(event); void this.handleNewEndpoint(event).catch((err) => {
console.error('[BackgroundSync] handleNewEndpoint failed:', err);
});
}), }),
await MatrixBackgroundSync.addListener('unifiedPushUnregistered', (event) => { await MatrixBackgroundSync.addListener('unifiedPushUnregistered', (event) => {
void this.handleUnregistered(event); void this.handleUnregistered(event);
@@ -260,6 +364,7 @@ class AndroidUnifiedPushManager {
if (!client) return; if (!client) return;
const status = await this.safeGetStatus(); const status = await this.safeGetStatus();
console.log('[BackgroundSync] Native status before pusher sync:', status);
if (status?.registered && status.endpoint) { if (status?.registered && status.endpoint) {
await this.upsertPusher(client, status.endpoint); await this.upsertPusher(client, status.endpoint);
} }
@@ -272,20 +377,40 @@ class AndroidUnifiedPushManager {
const appId = buildPusherAppId(deviceId); const appId = buildPusherAppId(deviceId);
const gatewayUrl = await resolveUnifiedPushGateway(endpoint); const gatewayUrl = await resolveUnifiedPushGateway(endpoint);
await mx.setPusher({ console.log('[BackgroundSync] Upserting Matrix pusher', {
kind: 'http', appId,
app_id: appId, endpoint,
pushkey: endpoint, gatewayUrl,
app_display_name: 'Paarrot', deviceId,
device_display_name: deviceId ?? 'Android', userId,
lang: 'en', });
data: {
url: gatewayUrl, try {
format: 'event_id_only', await mx.setPusher({
}, kind: 'http',
append: false, app_id: appId,
device_id: deviceId ?? undefined, pushkey: endpoint,
} as unknown as IPusherRequest); app_display_name: 'Paarrot',
device_display_name: deviceId ?? 'Android',
lang: 'en',
data: {
url: gatewayUrl,
format: 'event_id_only',
},
append: false,
device_id: deviceId ?? undefined,
} as unknown as IPusherRequest);
const pushers = (await mx.getPushers())?.pushers ?? [];
const thisPusher = pushers.find((p) => p.pushkey === endpoint && p.app_id === appId);
console.log('[BackgroundSync] Matrix pusher upserted successfully', {
totalPushers: pushers.length,
foundThisPusher: Boolean(thisPusher),
});
} catch (err) {
console.error('[BackgroundSync] Matrix pusher upsert failed:', err);
throw err;
}
saveStoredPusherState(userId, deviceId, { endpoint, appId }); saveStoredPusherState(userId, deviceId, { endpoint, appId });
} }
@@ -359,3 +484,35 @@ export const setAppForegroundState = async (foreground: boolean): Promise<void>
console.warn('[BackgroundSync] setAppForeground failed:', err); console.warn('[BackgroundSync] setAppForeground failed:', err);
} }
}; };
/**
* Re-opens the UnifiedPush distributor selection dialog.
* Allows users to re-select or change their push notification distributor without terminal access.
* Useful when the current endpoint becomes unavailable.
*/
export const requestResetPushRegistration = async (): Promise<{ success: boolean }> => {
if (!isBackgroundSyncSupported()) {
console.warn('[BackgroundSync] Background sync not supported on this platform');
return { success: false };
}
try {
const result = await MatrixBackgroundSync.requestDistributorSetup();
console.log('[BackgroundSync] requestDistributorSetup completed:', result);
return result ?? { success: false };
} catch (err) {
console.error('[BackgroundSync] requestDistributorSetup failed:', err);
return { success: false };
}
};
/** Returns the current native UnifiedPush status, or undefined if unavailable. */
export const getBackgroundSyncStatus = async (): Promise<UnifiedPushStatus | undefined> => {
if (!isBackgroundSyncSupported()) return undefined;
try {
return await MatrixBackgroundSync.getStatus();
} catch (err) {
console.warn('[BackgroundSync] getStatus failed:', err);
return undefined;
}
};

View File

@@ -0,0 +1,72 @@
import { Capacitor, registerPlugin } from '@capacitor/core';
export type PluginHostListItem = {
id: string;
name: string;
version?: string;
description?: string;
author?: string;
repository?: string;
thumbnail?: string;
homepage?: string;
tags?: string[];
installedDate: string;
path: string;
};
export type PluginHostResult<T> = {
success: boolean;
data?: T;
error?: string;
};
export interface PluginHostAPI {
getPath: () => Promise<PluginHostResult<string>>;
download: (
pluginId: string,
downloadUrl: string,
name: string
) => Promise<PluginHostResult<{ path: string }>>;
list: () => Promise<PluginHostResult<PluginHostListItem[]>>;
uninstall: (pluginId: string) => Promise<PluginHostResult<void>>;
readPluginCode: (pluginId: string) => Promise<PluginHostResult<string>>;
}
interface PluginStoragePlugin {
getPath(): Promise<PluginHostResult<string>>;
download(options: {
pluginId: string;
downloadUrl: string;
name: string;
}): Promise<PluginHostResult<{ path: string }>>;
list(): Promise<PluginHostResult<PluginHostListItem[]>>;
uninstall(options: { pluginId: string }): Promise<PluginHostResult<void>>;
readPluginCode(options: { pluginId: string }): Promise<PluginHostResult<string>>;
}
const PluginStorage = registerPlugin<PluginStoragePlugin>('PluginStorage');
const androidPluginHost: PluginHostAPI = {
getPath: () => PluginStorage.getPath(),
download: (pluginId, downloadUrl, name) =>
PluginStorage.download({ pluginId, downloadUrl, name }),
list: () => PluginStorage.list(),
uninstall: (pluginId) => PluginStorage.uninstall({ pluginId }),
readPluginCode: (pluginId) => PluginStorage.readPluginCode({ pluginId }),
};
/** Returns true when a native plugin filesystem host is available. */
export const isPluginHostSupported = (): boolean => getPluginHost() !== null;
/** Returns the active plugin host for Electron or Android. */
export const getPluginHost = (): PluginHostAPI | null => {
if (window.electron?.plugins) {
return window.electron.plugins;
}
if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android') {
return androidPluginHost;
}
return null;
};

View File

@@ -248,6 +248,24 @@ export const isAndroid = (): boolean => {
return ua.includes('android'); return ua.includes('android');
}; };
/** Returns true when running as a Capacitor app on Android. */
export const isCapacitorAndroid = (): boolean => {
if (typeof window === 'undefined') return false;
const cap = (window as { Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string } }).Capacitor;
return Boolean(cap?.isNativePlatform?.() && cap?.getPlatform?.() === 'android');
};
/**
* Android WebView: keep emoji fonts out of the body text stack and use system UI fonts.
*/
export const applyAndroidTypographyFix = (): void => {
if (!isCapacitorAndroid()) return;
const root = document.documentElement;
root.classList.add('android-capacitor');
root.style.setProperty('--font-secondary', 'system-ui, Roboto, "Noto Sans", sans-serif');
};
/** /**
* Apply safe area insets for mobile devices * Apply safe area insets for mobile devices
* On Android, CSS env() may not work, so we apply fallback padding * On Android, CSS env() may not work, so we apply fallback padding

View File

@@ -0,0 +1,81 @@
import { isCapacitorNative } from './tauri';
/**
* View Transitions can leave an invisible hit-blocking overlay on Android
* WebView after DM ↔ space navigations. Keep them off for native/touch shells.
*/
const shouldDisableViewTransitions = (): boolean => {
if (typeof window === 'undefined') return true;
if (isCapacitorNative()) return true;
if (window.matchMedia?.('(pointer: coarse)').matches) return true;
return false;
};
export const supportsViewTransitions = (): boolean => {
if (shouldDisableViewTransitions()) return false;
return 'startViewTransition' in document;
};
export const withViewTransition = async (callback: () => void | Promise<void>): Promise<void> => {
if (!supportsViewTransitions()) {
await callback();
return;
}
const doc = document as Document & {
startViewTransition: (callback: () => void | Promise<void>) => {
finished: Promise<void>;
updateCallbackDone: Promise<void>;
ready: Promise<void>;
};
};
try {
const transition = doc.startViewTransition(async () => {
await callback();
});
await transition.finished;
} catch {
await callback();
}
};
/**
* Global link interception for View Transitions. No-op on Capacitor / touch.
* Also fights hash-router navigations — leave disabled on mobile.
*/
export const enableViewTransitionsForNavigation = (): void => {
if (!supportsViewTransitions()) {
return;
}
const handleLinkClick = (event: MouseEvent) => {
const target = (event.target as HTMLElement).closest('a');
if (!target) return;
if (target.target === '_blank') return;
if (target.origin !== window.location.origin) return;
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
const url = new URL(target.href);
if (url.origin === window.location.origin) {
event.preventDefault();
withViewTransition(() => {
window.history.pushState({}, '', target.href);
window.dispatchEvent(new PopStateEvent('popstate'));
});
}
};
const handlePopState = () => {
withViewTransition(() => {
// URL already changed
});
};
window.addEventListener('click', handleLinkClick);
window.addEventListener('popstate', handlePopState);
};

23
overlay/src/font-setup.ts Normal file
View File

@@ -0,0 +1,23 @@
const cap =
typeof window !== 'undefined'
? (window as { Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string } }).Capacitor
: undefined;
export const isCapacitorAndroid = Boolean(
cap?.isNativePlatform?.() && cap?.getPlatform?.() === 'android'
);
// Stationery signatures use Caveat on all platforms (including Android WebView).
await import('@fontsource/caveat/500.css');
await import('@fontsource/caveat/600.css');
await import('@fontsource/caveat/700.css');
if (isCapacitorAndroid) {
document.documentElement.classList.add('android-capacitor');
document.documentElement.style.setProperty(
'--font-secondary',
'system-ui, Roboto, "Noto Sans", sans-serif'
);
} else {
await import('@fontsource-variable/inter');
}

2031
overlay/src/index.css Normal file

File diff suppressed because it is too large Load Diff

112
overlay/src/index.tsx Normal file
View File

@@ -0,0 +1,112 @@
/* eslint-disable import/first */
import React from 'react';
import { createRoot } from 'react-dom/client';
import { enableMapSet } from 'immer';
await import('./font-setup');
import 'folds/dist/style.css';
import { configClass, varsClass } from 'folds';
enableMapSet();
import './index.css';
import { trimTrailingSlash } from './app/utils/common';
import App from './app/pages/App';
import { applySafeAreaInsets, isTauri } from './app/utils/tauri';
import { enableViewTransitionsForNavigation } from './app/utils/viewTransitions';
// import i18n (needs to be bundled ;))
import './app/i18n';
document.body.classList.add(configClass, varsClass);
// Apply safe area insets for mobile devices
applySafeAreaInsets();
// Enable View Transitions API for smooth navigation
enableViewTransitionsForNavigation();
// Register Service Worker
if ('serviceWorker' in navigator) {
const swUrl =
import.meta.env.MODE === 'production'
? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
: `/dev-sw.js?dev-sw`;
navigator.serviceWorker.register(swUrl);
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data?.type === 'token' && event.data?.responseKey) {
const getCurrentAccessToken = async () => {
const { getCurrentAccessToken: getToken } = await import('./app/utils/auth');
return getToken();
};
getCurrentAccessToken().then((token) => {
event.source!.postMessage({
responseKey: event.data.responseKey,
token,
});
});
}
});
}
const isElectron = (): boolean => 'electron' in window;
async function checkForUpdates() {
if (isElectron()) {
console.log('Update check skipped - Electron handles updates natively');
return;
}
if (!isTauri() || isElectron()) {
console.log('Update check skipped - not running in Tauri');
return;
}
console.log('Checking for updates...');
try {
const { check } = await import('@tauri-apps/plugin-updater');
const { ask } = await import('@tauri-apps/plugin-dialog');
const { relaunch } = await import('@tauri-apps/plugin-process');
const update = await check();
console.log('Update check result:', update);
if (update) {
console.log(`Update available: ${update.version}`);
const shouldUpdate = await ask(
`A new version (${update.version}) is available. Would you like to update now?`,
{ title: 'Update Available', kind: 'info' }
);
if (shouldUpdate) {
console.log('User chose to update, downloading...');
await update.downloadAndInstall();
await relaunch();
}
} else {
console.log('App is up to date');
}
} catch (error) {
console.error('Failed to check for updates:', error);
}
}
const mountApp = () => {
const rootContainer = document.getElementById('root');
if (rootContainer === null) {
console.error('Root container element not found!');
return;
}
const root = createRoot(rootContainer);
root.render(<App />);
};
mountApp();
setTimeout(() => {
checkForUpdates();
}, 3000);

16
package-lock.json generated
View File

@@ -1,18 +1,19 @@
{ {
"name": "paarrot", "name": "paarrot",
"version": "4.11.77", "version": "4.11.121",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paarrot", "name": "paarrot",
"version": "4.11.77", "version": "4.11.121",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@capacitor/android": "8.3.0", "@capacitor/android": "8.3.0",
"@capacitor/cli": "8.3.0", "@capacitor/cli": "8.3.0",
"@capacitor/core": "8.3.0", "@capacitor/core": "8.3.0",
"@capacitor/local-notifications": "8.0.2" "@capacitor/local-notifications": "8.0.2",
"@capacitor/status-bar": "8.0.1"
}, },
"devDependencies": { "devDependencies": {
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
@@ -82,6 +83,15 @@
"@capacitor/core": ">=8.0.0" "@capacitor/core": ">=8.0.0"
} }
}, },
"node_modules/@capacitor/status-bar": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.1.tgz",
"integrity": "sha512-OR59dlbwvmrV5dKsC9lvwv48QaGbqcbSTBpk+9/WXWxXYSdXXdzJZU9p8oyNPAkuJhCdnSa3XmU43fZRPBJJ5w==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "paarrot", "name": "paarrot",
"version": "4.11.89", "version": "4.11.133",
"description": "Paarrot - A Matrix client based on Cinny", "description": "Paarrot - A Matrix client based on Cinny",
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
@@ -25,7 +25,8 @@
"@capacitor/android": "8.3.0", "@capacitor/android": "8.3.0",
"@capacitor/cli": "8.3.0", "@capacitor/cli": "8.3.0",
"@capacitor/core": "8.3.0", "@capacitor/core": "8.3.0",
"@capacitor/local-notifications": "8.0.2" "@capacitor/local-notifications": "8.0.2",
"@capacitor/status-bar": "8.0.1"
}, },
"devDependencies": { "devDependencies": {
"node-fetch": "3.3.2", "node-fetch": "3.3.2",

View File

@@ -30,6 +30,14 @@ const STASH_FLAG = join(ROOT, '.cinny-stash-pending');
const cinnyStatus = execSync('git status --porcelain', { cwd: CINNY, encoding: 'utf8' }); const cinnyStatus = execSync('git status --porcelain', { cwd: CINNY, encoding: 'utf8' });
if (cinnyStatus.trim()) { if (cinnyStatus.trim()) {
console.log('[apply-overlay] Stashing pre-existing cinny changes...'); console.log('[apply-overlay] Stashing pre-existing cinny changes...');
// Ensure android/ junction is excluded from the cinny git view — otherwise
// `git stash -u` would traverse the junction into the root android/ directory
// and delete files there when the stash is created.
const cinnyExclude = join(CINNY, '.git', 'info', 'exclude');
const excludeContent = existsSync(cinnyExclude) ? readFileSync(cinnyExclude, 'utf8') : '';
if (!excludeContent.includes('android/')) {
writeFileSync(cinnyExclude, excludeContent + (excludeContent.endsWith('\n') || !excludeContent ? '' : '\n') + 'android/\n', 'utf8');
}
execSync('git stash push -u -m "pre-overlay stash"', { cwd: CINNY, stdio: 'inherit' }); execSync('git stash push -u -m "pre-overlay stash"', { cwd: CINNY, stdio: 'inherit' });
writeFileSync(STASH_FLAG, '1', 'utf8'); writeFileSync(STASH_FLAG, '1', 'utf8');
console.log('[apply-overlay] Stash saved.'); console.log('[apply-overlay] Stash saved.');
@@ -120,21 +128,19 @@ if (!existsSync(androidJunction)) {
console.log(` android/ link already exists, skipping`); console.log(` android/ link already exists, skipping`);
} }
// 8. Sync version from root package.json into android/app/build.gradle // 8. Sync time-based version into android/app/build.gradle
console.log('[apply-overlay] Syncing version into build.gradle...'); console.log('[apply-overlay] Syncing time-based version into build.gradle...');
const rootPkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')); const buildTimestampMs = Date.now();
const version = rootPkg.version ?? '1.0.0'; const versionCode = Math.floor(buildTimestampMs / 1000);
const parts = version.split('.').map(Number); const versionName = new Date(buildTimestampMs).toISOString().replace('T', '.').replaceAll(':', '').replace('Z', '');
// versionCode: major*10000 + minor*100 + patch (e.g. 4.11.78 -> 41178)
const versionCode = (parts[0] ?? 0) * 10000 + (parts[1] ?? 0) * 100 + (parts[2] ?? 0);
const buildGradlePath = join(androidTarget, 'app', 'build.gradle'); const buildGradlePath = join(androidTarget, 'app', 'build.gradle');
let buildGradle = readFileSync(buildGradlePath, 'utf8'); let buildGradle = readFileSync(buildGradlePath, 'utf8');
buildGradle = buildGradle buildGradle = buildGradle
.replace(/versionCode\s+\d+/, `versionCode ${versionCode}`) .replace(/versionCode\s+\d+/, `versionCode ${versionCode}`)
.replace(/versionName\s+"[^"]+"/, `versionName "${version}"`); .replace(/versionName\s+"[^"]+"/, `versionName "${versionName}"`);
writeFileSync(buildGradlePath, buildGradle, 'utf8'); writeFileSync(buildGradlePath, buildGradle, 'utf8');
console.log(` versionName "${version}", versionCode ${versionCode}`); console.log(` versionName "${versionName}", versionCode ${versionCode}`);
// 9. Write android/local.properties with the current machine's Android SDK path // 9. Write android/local.properties with the current machine's Android SDK path
const androidHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT; const androidHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;