Compare commits

...

53 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
37 changed files with 5565 additions and 75 deletions

View File

@@ -102,29 +102,7 @@ jobs:
- name: Build Android APK
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
id: version
run: |
@@ -135,7 +113,14 @@ jobs:
fi
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
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
id: tag_check
run: |
@@ -147,7 +132,7 @@ jobs:
echo "Tag v$VERSION does not exist"
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Create and push tag
if: steps.tag_check.outputs.exists == 'false'
run: |
@@ -155,18 +140,9 @@ jobs:
git config user.email "actions@github.com"
git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}"
git push origin "v${{ steps.version.outputs.VERSION }}"
- name: Download all artifacts
uses: actions/download-artifact@v3
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/
# Upload via the internal Gitea API. actions/upload-artifact times out against
# git.ruv.wtf:443 from this runner; synbox:8418 is reachable on the LAN.
- name: Create or Update Gitea Release
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -175,10 +151,10 @@ jobs:
TAG_NAME="v${VERSION}"
API_BASE="http://synbox.ruv.wtf:8418/api/v1"
REPO="litruv/cinny-mobile"
RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
if [ -z "$RELEASE_ID" ]; then
echo "Creating new release for ${TAG_NAME}..."
RELEASE_ID=$(curl -s -X POST \
@@ -198,7 +174,7 @@ jobs:
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
done
fi
echo "Uploading assets..."
for FILE in release-files/*; do
FILENAME="Paarrot-${VERSION}.apk"
@@ -210,9 +186,9 @@ jobs:
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
echo ""
done
echo "Gitea release complete!"
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GHTOKEN }}
@@ -220,10 +196,10 @@ jobs:
VERSION="${{ steps.version.outputs.VERSION }}"
TAG_NAME="v${VERSION}"
GITHUB_REPO="Paarrot/Paarrot-Mobile"
RELEASE_ID=$(curl -s -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
if [ -z "$RELEASE_ID" ]; then
echo "Creating new GitHub release for ${TAG_NAME}..."
RELEASE_ID=$(curl -s -X POST \
@@ -235,11 +211,11 @@ jobs:
else
echo "GitHub release exists with ID: ${RELEASE_ID}"
fi
echo "Uploading assets to GitHub..."
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}//')
for FILE in release-files/*; do
FILENAME="Paarrot-${VERSION}.apk"
echo "Uploading ${FILENAME} to GitHub..."
@@ -250,5 +226,5 @@ jobs:
"${UPLOAD_URL}?name=${FILENAME}"
echo ""
done
echo "GitHub release complete!"

2
.gitmodules vendored
View File

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

View File

@@ -27,8 +27,8 @@ android {
applicationId "com.paarrot.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1783526741
versionName "2026-07-08.160541.766"
versionCode 1783582912
versionName "2026-07-09.074152.772"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

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,6 +1,8 @@
package com.paarrot.app;
import android.os.Bundle;
import android.webkit.WebView;
import androidx.core.view.ViewCompat;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
@@ -10,5 +12,18 @@ public class MainActivity extends BridgeActivity {
registerPlugin(ShareHandlerPlugin.class);
registerPlugin(PluginStoragePlugin.class);
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

@@ -151,6 +151,21 @@ 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) {
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
val homeserver = prefs.getString(EXTRA_HOMESERVER, null)?.trimEnd('/') ?: return
@@ -159,10 +174,20 @@ class MatrixSyncService : Service() {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
ensureMessageChannel(nm)
val notifyCtx = loadNotifyContext(homeserver, token, myUserId)
val roomIds = joinedRooms.keys().asSequence().toList()
for (roomId in roomIds) {
val timeline = joinedRooms.optJSONObject(roomId)
?.optJSONObject("timeline") ?: continue
val roomData = joinedRooms.optJSONObject(roomId) ?: 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
for (i in 0 until events.length()) {
@@ -174,6 +199,8 @@ class MatrixSyncService : Service() {
if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue
val content = event.optJSONObject("content") ?: continue
if (mentionsOnly && !isSpecialMessage(content, notifyCtx)) continue
val msgtype = content.optString("msgtype")
val rawBody = content.optString("body")
val body = when (msgtype) {
@@ -203,6 +230,144 @@ class MatrixSyncService : Service() {
}
}
/** 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])

View File

@@ -62,7 +62,6 @@ object ShareIntentStore {
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return
val receivedAt = System.currentTimeMillis()
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
@@ -86,8 +85,29 @@ object ShareIntentStore {
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,

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>

2
cinny

Submodule cinny updated: b4258278d8...b52926f7d8

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,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,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

@@ -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,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,4 @@
import { useAtomValue } from 'jotai';
import { useAtom, useSetAtom } from 'jotai';
import { useAtomValue, useStore } from 'jotai';
import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
import { useNavigate } from 'react-router-dom';
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
@@ -23,13 +22,12 @@ import {
guessPerfectParent,
} from '../../utils/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 { roomToParentsAtom } from '../../state/room/roomToParents';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { plainToEditorInput, toPlainText } from '../../components/editor';
import { ShareRoomPicker } from '../../features/ShareRoomPicker';
import {
isTauri,
@@ -46,10 +44,10 @@ import {
} from '../../utils/backgroundSync';
import {
TUploadItem,
roomIdToMsgDraftAtomFamily,
roomIdToUploadItemsAtomFamily,
} from '../../state/room/roomInputDrafts';
import { encryptFile } from '../../utils/matrix';
import { fulfilledPromiseSettledResult } from '../../utils/common';
import { safeFile } from '../../utils/mimeTypes';
import {
AndroidSharePayload,
clearPendingAndroidShare,
@@ -378,6 +376,14 @@ function MessageNotifications() {
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 (
showNotifications &&
((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
@@ -399,7 +405,6 @@ function MessageNotifications() {
messageBody = typeof content.body === 'string' ? content.body : undefined;
}
const isDm = room.getJoinedMemberCount() === 2 && !room.isSpaceRoom();
notify({
roomName: room.name ?? 'Unknown',
roomAvatar: avatarMxc
@@ -430,6 +435,7 @@ function MessageNotifications() {
notify,
selectedRoomId,
useAuthentication,
mDirects,
]);
return null;
@@ -517,10 +523,71 @@ function TaskbarFlashStopper() {
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) => {
@@ -543,7 +610,7 @@ function AndroidShareIntentHandler() {
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;
@@ -594,6 +661,22 @@ function AndroidShareIntentHandler() {
[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;
@@ -602,7 +685,7 @@ function AndroidShareIntentHandler() {
.then((applied) => {
if (applied) {
setPendingShare(null);
// Navigate to the selected room
const isDirect = mDirects.has(roomId);
if (isDirect) {
@@ -640,7 +723,7 @@ function AndroidShareIntentHandler() {
getPendingAndroidShare()
.then((share) => {
if (mounted && share) {
setPendingShare(share);
ingestShare(share);
}
})
.catch((err) => {
@@ -649,7 +732,7 @@ function AndroidShareIntentHandler() {
listenForAndroidShares((share) => {
if (mounted) {
setPendingShare(share);
ingestShare(share);
}
})
.then((handle) => {
@@ -663,7 +746,7 @@ function AndroidShareIntentHandler() {
mounted = false;
void listenerHandle?.remove();
};
}, []);
}, [ingestShare]);
if (!pendingShare) return null;

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);
};

View File

@@ -7,6 +7,11 @@ 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(

File diff suppressed because it is too large Load Diff

16
package-lock.json generated
View File

@@ -1,18 +1,19 @@
{
"name": "paarrot",
"version": "4.11.77",
"version": "4.11.121",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paarrot",
"version": "4.11.77",
"version": "4.11.121",
"license": "AGPL-3.0-only",
"dependencies": {
"@capacitor/android": "8.3.0",
"@capacitor/cli": "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": {
"node-fetch": "3.3.2",
@@ -82,6 +83,15 @@
"@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": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "paarrot",
"version": "4.11.112",
"version": "4.11.133",
"description": "Paarrot - A Matrix client based on Cinny",
"engines": {
"node": ">=18.0.0"
@@ -25,7 +25,8 @@
"@capacitor/android": "8.3.0",
"@capacitor/cli": "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": {
"node-fetch": "3.3.2",