Compare commits

..

34 Commits

Author SHA1 Message Date
GitHub Actions
3940441d9b chore: bump version to 4.11.135 [skip ci] 2026-07-24 04:47:38 +00:00
cfabcc5fbf Show decrypted notification bodies and authenticated sender avatars.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 5m8s
Wait for E2EE cleartext before building tray text, and post Android notifications natively so dynamic avatars fetched with secure media auth can appear as large icons.
2026-07-24 14:47:32 +10:00
GitHub Actions
a40319e8c8 chore: bump version to 4.11.134 [skip ci] 2026-07-24 04:36:18 +00:00
45bc475340 Group Android notifications by space and clear them on read.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 4m52s
Use stable per-room notification IDs with Directs/Spaces/Home channels and space group summaries so the shade nests deeper than a flat Messages list, and dismiss those tray entries when a room is marked read.
2026-07-24 14:36:11 +10:00
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
28 changed files with 3643 additions and 231 deletions

View File

@@ -103,28 +103,6 @@ jobs:
- name: Build Android APK - name: Build Android APK
run: npm run android:apk:linux run: npm run android:apk:linux
- name: Upload APK
uses: actions/upload-artifact@v3
with:
name: Paarrot-Android.apk
path: android/app/build/outputs/apk/debug/Paarrot-*.apk
create-release:
runs-on: ubuntu-latest
needs: [increment-version, build-android]
if: always() && needs.build-android.result == 'success'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.ref_name }}
- name: Pull latest (after version bump)
if: github.ref == 'refs/heads/master'
run: git pull origin master
- name: Get version - name: Get version
id: version id: version
run: | run: |
@@ -136,6 +114,13 @@ jobs:
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION" echo "Version: $VERSION"
- name: Prepare release files
run: |
mkdir -p release-files
find android/app/build/outputs/apk/debug -type f -name "Paarrot-*.apk" -exec cp {} release-files/ \;
ls -la release-files/
test -n "$(ls -A release-files/*.apk 2>/dev/null)"
- name: Check if tag exists - name: Check if tag exists
id: tag_check id: tag_check
run: | run: |
@@ -156,17 +141,8 @@ jobs:
git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}" git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}"
git push origin "v${{ steps.version.outputs.VERSION }}" git push origin "v${{ steps.version.outputs.VERSION }}"
- name: Download all artifacts # Upload via the internal Gitea API. actions/upload-artifact times out against
uses: actions/download-artifact@v3 # git.ruv.wtf:443 from this runner; synbox:8418 is reachable on the LAN.
with:
path: artifacts
- name: Prepare release files
run: |
mkdir -p release-files
find artifacts -type f -name "*.apk" -exec cp {} release-files/ \;
ls -la release-files/
- name: Create or Update Gitea Release - name: Create or Update Gitea Release
env: env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}

2
.gitmodules vendored
View File

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

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

View File

@@ -10,6 +10,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.util.Base64
import android.util.Log import android.util.Log
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
@@ -151,18 +152,43 @@ class MatrixSyncService : Service() {
} }
} }
private enum class RoomNotifyMode {
MUTE,
ALL_MESSAGES,
MENTIONS_AND_KEYWORDS,
}
private data class NotifyContext(
val myUserId: String,
val myDisplayName: String?,
val myLocalpart: String?,
val keywords: List<String>,
val directRoomIds: Set<String>,
val pushRules: JSONObject?,
)
private fun processRoomEvents(sync: JSONObject, myUserId: String) { private fun processRoomEvents(sync: JSONObject, myUserId: String) {
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE) val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
val homeserver = prefs.getString(EXTRA_HOMESERVER, null)?.trimEnd('/') ?: return val homeserver = prefs.getString(EXTRA_HOMESERVER, null)?.trimEnd('/') ?: return
val token = prefs.getString(EXTRA_TOKEN, null) ?: return val token = prefs.getString(EXTRA_TOKEN, null) ?: return
val joinedRooms = sync.optJSONObject("rooms")?.optJSONObject("join") ?: return val joinedRooms = sync.optJSONObject("rooms")?.optJSONObject("join") ?: return
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
ensureMessageChannel(nm) ensureMessageChannels(nm)
val notifyCtx = loadNotifyContext(homeserver, token, myUserId)
val roomIds = joinedRooms.keys().asSequence().toList() val roomIds = joinedRooms.keys().asSequence().toList()
for (roomId in roomIds) { for (roomId in roomIds) {
val timeline = joinedRooms.optJSONObject(roomId) val roomData = joinedRooms.optJSONObject(roomId) ?: continue
?.optJSONObject("timeline") ?: continue val mode = resolveRoomNotifyMode(roomId, notifyCtx)
// Homeserver unread counts already apply push rules — skip rooms with nothing to notify.
val unread = roomData.optJSONObject("unread_notifications")
val notificationCount = unread?.optInt("notification_count", 0) ?: 0
if (mode == RoomNotifyMode.MUTE || notificationCount <= 0) continue
val mentionsOnly = mode == RoomNotifyMode.MENTIONS_AND_KEYWORDS
val timeline = roomData.optJSONObject("timeline") ?: continue
val events = timeline.optJSONArray("events") ?: continue val events = timeline.optJSONArray("events") ?: continue
for (i in 0 until events.length()) { for (i in 0 until events.length()) {
@@ -174,6 +200,8 @@ class MatrixSyncService : Service() {
if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue
val content = event.optJSONObject("content") ?: continue val content = event.optJSONObject("content") ?: continue
if (mentionsOnly && !isSpecialMessage(content, notifyCtx)) continue
val msgtype = content.optString("msgtype") val msgtype = content.optString("msgtype")
val rawBody = content.optString("body") val rawBody = content.optString("body")
val body = when (msgtype) { val body = when (msgtype) {
@@ -193,16 +221,163 @@ class MatrixSyncService : Service() {
val inlineImage: Bitmap? = if ( val inlineImage: Bitmap? = if (
(msgtype == "m.image" || msgtype == "m.sticker") && !content.has("file") (msgtype == "m.image" || msgtype == "m.sticker") && !content.has("file")
) { ) {
content.optString("url").takeIf { it.startsWith("mxc://") } content.optString("url").takeIf { it.startsWith("mxc://") }?.let { mxc ->
?.let { mxcToDownloadUrl(it, homeserver) } mxcToDownloadUrls(mxc, homeserver)
?.let { downloadBitmap(it, token) } .firstNotNullOfOrNull { downloadBitmap(it, token) }
}
} else null } else null
showMessageNotification(nm, profile.displayName, body, profile.avatar, inlineImage) showMessageNotification(
nm,
roomId,
profile.displayName,
body,
profile.avatar,
inlineImage,
resolveGroupInfo(roomId, notifyCtx),
)
} }
} }
} }
/** 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 { private fun resolveProfile(mxid: String, homeserver: String, token: String): UserProfile {
val cachedName = displayNameCache[mxid] val cachedName = displayNameCache[mxid]
if (cachedName != null) return UserProfile(cachedName, avatarCache[mxid]) if (cachedName != null) return UserProfile(cachedName, avatarCache[mxid])
@@ -231,9 +406,7 @@ class MatrixSyncService : Service() {
conn.disconnect() conn.disconnect()
} }
displayNameCache[mxid] = displayName displayNameCache[mxid] = displayName
val avatar = avatarMxc val avatar = avatarMxc?.let { downloadAvatarBitmap(it, homeserver, token, size = 96) }
?.let { mxcToThumbnailUrl(it, homeserver, size = 96) }
?.let { downloadBitmap(it, token) }
avatarCache[mxid] = avatar avatarCache[mxid] = avatar
UserProfile(displayName, avatar) UserProfile(displayName, avatar)
} catch (e: Exception) { } catch (e: Exception) {
@@ -244,24 +417,47 @@ class MatrixSyncService : Service() {
} }
} }
private fun mxcToThumbnailUrl(mxcUrl: String, homeserver: String, size: Int): String? { private fun mxcToThumbnailUrls(mxcUrl: String, homeserver: String, size: Int): List<String> {
val withoutScheme = mxcUrl.removePrefix("mxc://") val withoutScheme = mxcUrl.removePrefix("mxc://")
val slash = withoutScheme.indexOf('/') val slash = withoutScheme.indexOf('/')
if (slash < 0) return null if (slash < 0) return emptyList()
val serverName = withoutScheme.substring(0, slash) val serverName = withoutScheme.substring(0, slash)
val mediaId = withoutScheme.substring(slash + 1) val mediaId = withoutScheme.substring(slash + 1)
return "$homeserver/_matrix/media/v3/thumbnail/$serverName/$mediaId?width=$size&height=$size&method=crop" val query = "width=$size&height=$size&method=crop"
// Prefer MSC3916 authenticated media, fall back to legacy media repo.
return listOf(
"$homeserver/_matrix/client/v1/media/thumbnail/$serverName/$mediaId?$query",
"$homeserver/_matrix/media/v3/thumbnail/$serverName/$mediaId?$query",
)
} }
private fun mxcToDownloadUrl(mxcUrl: String, homeserver: String): String? { private fun mxcToDownloadUrls(mxcUrl: String, homeserver: String): List<String> {
val withoutScheme = mxcUrl.removePrefix("mxc://") val withoutScheme = mxcUrl.removePrefix("mxc://")
val slash = withoutScheme.indexOf('/') val slash = withoutScheme.indexOf('/')
if (slash < 0) return null if (slash < 0) return emptyList()
val serverName = withoutScheme.substring(0, slash) val serverName = withoutScheme.substring(0, slash)
val mediaId = withoutScheme.substring(slash + 1) val mediaId = withoutScheme.substring(slash + 1)
return "$homeserver/_matrix/media/v3/download/$serverName/$mediaId" return listOf(
"$homeserver/_matrix/client/v1/media/download/$serverName/$mediaId",
"$homeserver/_matrix/media/v3/download/$serverName/$mediaId",
)
} }
private fun downloadAvatarBitmap(
mxcUrl: String,
homeserver: String,
token: String,
size: Int,
): Bitmap? {
for (url in mxcToThumbnailUrls(mxcUrl, homeserver, size)) {
downloadBitmap(url, token)?.let { return it }
}
return null
}
private fun mxcToDownloadUrl(mxcUrl: String, homeserver: String): String? =
mxcToDownloadUrls(mxcUrl, homeserver).firstOrNull()
private fun downloadBitmap(urlString: String, token: String): Bitmap? { private fun downloadBitmap(urlString: String, token: String): Bitmap? {
return try { return try {
val conn = URL(urlString).openConnection() as HttpURLConnection val conn = URL(urlString).openConnection() as HttpURLConnection
@@ -280,39 +476,83 @@ class MatrixSyncService : Service() {
} }
} }
private data class NotificationGroupInfo(
val groupId: String,
val groupName: String,
val roomName: String,
val kind: String,
)
private fun resolveGroupInfo(roomId: String, ctx: NotifyContext): NotificationGroupInfo {
val mapped = loadNotificationGroupMap()[roomId]
if (mapped != null) return mapped
return if (ctx.directRoomIds.contains(roomId)) {
NotificationGroupInfo(
groupId = GROUP_DIRECTS,
groupName = "Direct messages",
roomName = roomId,
kind = "direct",
)
} else {
NotificationGroupInfo(
groupId = GROUP_HOME,
groupName = "Home",
roomName = roomId,
kind = "home",
)
}
}
private fun loadNotificationGroupMap(): Map<String, NotificationGroupInfo> {
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
val raw = prefs.getString(KEY_NOTIFICATION_GROUPS, null) ?: return emptyMap()
return try {
val root = JSONObject(raw)
val out = mutableMapOf<String, NotificationGroupInfo>()
val keys = root.keys()
while (keys.hasNext()) {
val roomId = keys.next()
val obj = root.optJSONObject(roomId) ?: continue
out[roomId] = NotificationGroupInfo(
groupId = obj.optString("groupId").ifBlank { GROUP_HOME },
groupName = obj.optString("groupName").ifBlank { "Home" },
roomName = obj.optString("roomName").ifBlank { roomId },
kind = obj.optString("kind").ifBlank { "home" },
)
}
out
} catch (e: Exception) {
Log.w(TAG, "Failed to parse notification group map: ${e.message}")
emptyMap()
}
}
private fun channelIdForKind(kind: String): String = channelIdForKindStatic(kind)
private fun showMessageNotification( private fun showMessageNotification(
nm: NotificationManager, nm: NotificationManager,
roomId: String,
sender: String, sender: String,
body: String, body: String,
largeIcon: Bitmap? = null, largeIcon: Bitmap? = null,
inlineImage: Bitmap? = null, inlineImage: Bitmap? = null,
groupInfo: NotificationGroupInfo,
) { ) {
val launchIntent = packageManager.getLaunchIntentForPackage(packageName) val isDm = groupInfo.kind == "direct"
?: Intent(this, MainActivity::class.java) val title = if (isDm) sender else groupInfo.roomName.ifBlank { sender }
val pi = PendingIntent.getActivity( val text = if (isDm) body else "$sender: $body"
this, 0, launchIntent, postMessageNotification(
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, this,
roomId = roomId,
title = title,
body = text,
groupId = groupInfo.groupId,
groupName = groupInfo.groupName,
kind = groupInfo.kind,
largeIcon = largeIcon,
inlineImage = inlineImage,
) )
val builder = NotificationCompat.Builder(this, CHANNEL_MESSAGES)
.setSmallIcon(R.drawable.ic_stat_paarrot)
.setContentTitle(sender)
.setContentText(body)
.setAutoCancel(true)
.setContentIntent(pi)
.setPriority(NotificationCompat.PRIORITY_HIGH)
if (largeIcon != null) builder.setLargeIcon(largeIcon)
if (inlineImage != null) {
builder.setStyle(
NotificationCompat.BigPictureStyle()
.bigPicture(inlineImage)
.bigLargeIcon(null as Bitmap?)
)
}
nm.notify(System.currentTimeMillis().toInt(), builder.build())
} }
private fun buildStatusNotification(): Notification { private fun buildStatusNotification(): Notification {
@@ -346,24 +586,8 @@ class MatrixSyncService : Service() {
} }
} }
private fun ensureMessageChannel(nm: NotificationManager) { private fun ensureMessageChannels(nm: NotificationManager) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { ensureMessageChannels(this)
val soundUri = Uri.parse("android.resource://$packageName/${R.raw.paarrot_notification}")
val channel = NotificationChannel(
CHANNEL_MESSAGES, "Messages",
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "Matrix message notifications"
setSound(
soundUri,
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build(),
)
}
nm.createNotificationChannel(channel)
}
} }
companion object { companion object {
@@ -372,16 +596,202 @@ class MatrixSyncService : Service() {
const val EXTRA_TOKEN = "access_token" const val EXTRA_TOKEN = "access_token"
const val EXTRA_USER_ID = "user_id" const val EXTRA_USER_ID = "user_id"
const val EXTRA_DEVICE_ID = "device_id" const val EXTRA_DEVICE_ID = "device_id"
const val EXTRA_ROOM_ID = "room_id"
const val EXTRA_GROUP_ID = "group_id"
const val EXTRA_TRIGGER_REASON = "trigger_reason" const val EXTRA_TRIGGER_REASON = "trigger_reason"
const val KEY_NOTIFICATION_GROUPS = "notification_groups"
const val PREFS = "matrix_sync_prefs" const val PREFS = "matrix_sync_prefs"
const val KEY_SINCE = "since_token" const val KEY_SINCE = "since_token"
private const val KEY_LAST_WAKE_MS = "last_wake_ms" private const val KEY_LAST_WAKE_MS = "last_wake_ms"
private const val NOTIF_ID_STATUS = 1001 private const val NOTIF_ID_STATUS = 1001
private const val CHANNEL_STATUS = "sync_status" private const val CHANNEL_STATUS = "sync_status"
private const val CHANNEL_MESSAGES = "messages_paarrot" private const val CHANNEL_MESSAGES = "messages_paarrot"
private const val CHANNEL_DIRECTS = "messages_directs"
private const val CHANNEL_SPACES = "messages_spaces"
private const val CHANNEL_HOME = "messages_home"
private const val GROUP_DIRECTS = "paarrot_directs"
private const val GROUP_HOME = "paarrot_home"
private const val MIN_WAKE_INTERVAL_MS = 7_500L private const val MIN_WAKE_INTERVAL_MS = 7_500L
const val MODE_ONE_SHOT = "one_shot" const val MODE_ONE_SHOT = "one_shot"
/** Same hashing scheme as JS `notificationIdForRoom` so clear-on-read hits both paths. */
fun notificationIdForRoom(roomId: String): Int {
var hash = 0
for (ch in roomId) {
hash = (hash shl 5) - hash + ch.code
}
// Match JS Math.abs(...); avoid Int.MIN_VALUE abs edge case.
val positive = if (hash == Int.MIN_VALUE) 0 else kotlin.math.abs(hash)
var id = positive % 2147483647
if (id == 0 || id == NOTIF_ID_STATUS) {
id = NOTIF_ID_STATUS + 1
}
return id
}
fun notificationIdForGroupSummary(groupId: String): Int =
notificationIdForRoom("summary:$groupId")
private fun channelIdForKindStatic(kind: String): String = when (kind) {
"direct" -> CHANNEL_DIRECTS
"space" -> CHANNEL_SPACES
else -> CHANNEL_HOME
}
fun ensureMessageChannels(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val soundUri = Uri.parse("android.resource://${context.packageName}/${R.raw.paarrot_notification}")
val soundAttrs = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
val channels = listOf(
Triple(CHANNEL_DIRECTS, "Direct messages", "One-to-one Matrix conversations"),
Triple(CHANNEL_SPACES, "Spaces", "Messages from rooms inside Spaces"),
Triple(CHANNEL_HOME, "Other rooms", "Rooms that are not in a Space"),
Triple(CHANNEL_MESSAGES, "Messages", "Legacy message channel"),
)
for ((id, name, description) in channels) {
val channel = NotificationChannel(id, name, NotificationManager.IMPORTANCE_HIGH).apply {
this.description = description
setSound(soundUri, soundAttrs)
}
nm.createNotificationChannel(channel)
}
}
fun decodeBase64Bitmap(base64: String?): Bitmap? {
if (base64.isNullOrBlank()) return null
return try {
val raw = if (base64.contains(',')) base64.substringAfter(',') else base64
val bytes = Base64.decode(raw, Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} catch (e: Exception) {
Log.w(TAG, "Failed to decode notification icon: ${e.message}")
null
}
}
/**
* Posts a room notification (+ space/DM group summary) for both background sync
* and JS-driven Capacitor notifications (with optional avatar bitmap).
*/
fun postMessageNotification(
context: Context,
roomId: String,
title: String,
body: String,
groupId: String,
groupName: String,
kind: String,
largeIcon: Bitmap? = null,
inlineImage: Bitmap? = null,
) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
ensureMessageChannels(context)
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
?: Intent(context, MainActivity::class.java)
val roomNotifId = notificationIdForRoom(roomId)
val pi = PendingIntent.getActivity(
context, roomNotifId, launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val channelId = channelIdForKindStatic(kind)
val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_stat_paarrot)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setContentIntent(pi)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setGroup(groupId)
.setOnlyAlertOnce(true)
.setSubText(groupName)
builder.extras.putString(EXTRA_ROOM_ID, roomId)
builder.extras.putString(EXTRA_GROUP_ID, groupId)
if (largeIcon != null) builder.setLargeIcon(largeIcon)
if (inlineImage != null) {
builder.setStyle(
NotificationCompat.BigPictureStyle()
.bigPicture(inlineImage)
.bigLargeIcon(null as Bitmap?)
)
} else {
builder.setStyle(
NotificationCompat.BigTextStyle()
.bigText(body)
.setSummaryText(groupName)
)
}
nm.notify(roomNotifId, builder.build())
val summaryId = notificationIdForGroupSummary(groupId)
val summary = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_stat_paarrot)
.setContentTitle(groupName)
.setContentText("New messages")
.setAutoCancel(true)
.setContentIntent(pi)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setGroup(groupId)
.setGroupSummary(true)
.setStyle(
NotificationCompat.InboxStyle()
.setBigContentTitle(groupName)
.setSummaryText(groupName)
)
summary.extras.putString(EXTRA_GROUP_ID, groupId)
nm.notify(summaryId, summary.build())
}
/** Cancel the tray notification posted for [roomId], if any. */
fun clearRoomNotifications(context: Context, roomId: String) {
if (roomId.isBlank()) return
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val roomNotifId = notificationIdForRoom(roomId)
var groupId: String? = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
groupId = nm.activeNotifications
.firstOrNull { it.id == roomNotifId }
?.notification?.extras?.getString(EXTRA_GROUP_ID)
}
nm.cancel(roomNotifId)
// Also drop any legacy stacked notifications that still carry this room id.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
for (active in nm.activeNotifications) {
val tagged = active.notification.extras.getString(EXTRA_ROOM_ID)
if (tagged == roomId && active.id != roomNotifId) {
nm.cancel(active.id)
}
}
if (!groupId.isNullOrBlank()) {
val summaryId = notificationIdForGroupSummary(groupId)
val siblingsRemain = nm.activeNotifications.any { active ->
active.id != summaryId &&
active.notification.extras.getString(EXTRA_GROUP_ID) == groupId
}
if (!siblingsRemain) {
nm.cancel(summaryId)
}
}
}
}
/** Set by [SyncServicePlugin] — true when the Capacitor WebView UI is visible. */ /** Set by [SyncServicePlugin] — true when the Capacitor WebView UI is visible. */
@Volatile @Volatile
var appInForeground = false var appInForeground = false

View File

@@ -62,7 +62,6 @@ object ShareIntentStore {
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return
val receivedAt = System.currentTimeMillis()
val text = intent.getStringExtra(Intent.EXTRA_TEXT) val text = intent.getStringExtra(Intent.EXTRA_TEXT)
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT) val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
@@ -86,8 +85,29 @@ object ShareIntentStore {
uri?.let { uris.add(it) } 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) } val copiedFiles = uris.mapNotNull { copySharedUri(context, it, receivedAt) }
if (copiedFiles.isEmpty() && text.isNullOrBlank() && subject.isNullOrBlank()) return
val payload = SharePayload( val payload = SharePayload(
text = text, text = text,
subject = subject, subject = subject,

View File

@@ -17,6 +17,9 @@ import com.getcapacitor.annotation.CapacitorPlugin
* - `stop()` — clear persisted credentials and unregister UnifiedPush * - `stop()` — clear persisted credentials and unregister UnifiedPush
* - `setAppForeground({ foreground })` — tell the service whether the app UI is visible * - `setAppForeground({ foreground })` — tell the service whether the app UI is visible
* - `getStatus()` — returns current fetch and UnifiedPush state * - `getStatus()` — returns current fetch and UnifiedPush state
* - `clearRoomNotifications({ roomId })` — dismiss native tray notifs for a room
* - `setNotificationGroups({ rooms })` — persist space/DM grouping for tray nesting
* - `showNotification({ title, body, roomId, groupId, groupName, kind, largeIconBase64 })`
*/ */
@CapacitorPlugin(name = "MatrixBackgroundSync") @CapacitorPlugin(name = "MatrixBackgroundSync")
class SyncServicePlugin : Plugin() { class SyncServicePlugin : Plugin() {
@@ -97,6 +100,59 @@ class SyncServicePlugin : Plugin() {
call.resolve(result) call.resolve(result)
} }
/**
* Cancels native tray notifications for a Matrix room after it has been marked as read.
*/
@PluginMethod
fun clearRoomNotifications(call: PluginCall) {
val roomId = call.getString("roomId")
?: return call.reject("roomId required")
MatrixSyncService.clearRoomNotifications(context, roomId)
call.resolve(JSObject().put("cleared", true))
}
/**
* Persists room → space/group metadata from the JS layer so background
* notifications can nest under Direct messages / Space name / Home.
*/
@PluginMethod
fun setNotificationGroups(call: PluginCall) {
val rooms = call.getObject("rooms")
?: return call.reject("rooms required")
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString(MatrixSyncService.KEY_NOTIFICATION_GROUPS, rooms.toString())
.apply()
call.resolve(JSObject().put("success", true))
}
/**
* Shows a message notification from the JS layer (supports dynamic avatar icons).
* Capacitor LocalNotifications cannot load authenticated/dynamic large icons, so
* we post through the same native NotificationManager path as background sync.
*/
@PluginMethod
fun showNotification(call: PluginCall) {
val title = call.getString("title") ?: return call.reject("title required")
val body = call.getString("body") ?: return call.reject("body required")
val roomId = call.getString("roomId") ?: return call.reject("roomId required")
val groupId = call.getString("groupId") ?: "paarrot_home"
val groupName = call.getString("groupName") ?: "Home"
val kind = call.getString("kind") ?: "home"
val largeIconBase64 = call.getString("largeIconBase64")
MatrixSyncService.postMessageNotification(
context = context,
roomId = roomId,
title = title,
body = body,
groupId = groupId,
groupName = groupName,
kind = kind,
largeIcon = MatrixSyncService.decodeBase64Bitmap(largeIconBase64),
)
call.resolve(JSObject().put("shown", true))
}
companion object { companion object {
const val PREFS = "sync_service_prefs" const val PREFS = "sync_service_prefs"
} }

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: 9c7f71896c...b52926f7d8

View File

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

View File

@@ -220,6 +220,12 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
const getEndedTouch = (evt: TouchEvent, touchId: number) => const getEndedTouch = (evt: TouchEvent, touchId: number) =>
Array.from(evt.changedTouches).find((touch) => touch.identifier === touchId); 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) => { const handleTouchStart = (evt: TouchEvent) => {
if (gestureRef.current.phase !== 'idle') return; if (gestureRef.current.phase !== 'idle') return;
@@ -232,7 +238,12 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
const messageEl = findMessageElement(target); const messageEl = findMessageElement(target);
const messageId = messageEl?.getAttribute('data-message-id') ?? null; const messageId = messageEl?.getAttribute('data-message-id') ?? null;
const canStartBack = backEnabled && !shouldIgnoreBackTarget(target); // 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 = const canStartReply =
replyEnabled && replyEnabled &&
messageEl && messageEl &&
@@ -242,23 +253,11 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
if (!canStartBack && !canStartReply) return; if (!canStartBack && !canStartReply) return;
if (touch.clientX <= EDGE_WIDTH && canStartBack) { const contentLeft = rootRef.current?.getBoundingClientRect().left ?? 0;
evt.preventDefault(); const nearContentLeftEdge =
gestureRef.current = { touch.clientX >= contentLeft && touch.clientX <= contentLeft + EDGE_WIDTH;
phase: 'back',
touchId: touch.identifier,
startX: touch.clientX,
startY: touch.clientY,
messageEl: null,
transformEl: null,
messageId: null,
edgeBack: true,
offset: 0,
};
document.documentElement.classList.add('mobile-gesture-lock');
return;
}
// Prefer pending over immediate back+preventDefault so a tap still clicks.
gestureRef.current = { gestureRef.current = {
phase: 'pending', phase: 'pending',
touchId: touch.identifier, touchId: touch.identifier,
@@ -267,7 +266,7 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
messageEl: canStartReply ? messageEl : null, messageEl: canStartReply ? messageEl : null,
transformEl: canStartReply && messageEl ? findReplyTransformTarget(messageEl) : null, transformEl: canStartReply && messageEl ? findReplyTransformTarget(messageEl) : null,
messageId: canStartReply ? messageId : null, messageId: canStartReply ? messageId : null,
edgeBack: false, edgeBack: nearContentLeftEdge && canStartBack,
offset: 0, offset: 0,
}; };
}; };

View File

@@ -7,6 +7,7 @@ import * as swipeCss from '../mobile/mobile-gestures.css';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel'; import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
import { useShowCompactMasterView } from '../../hooks/useCompactNav'; import { useShowCompactMasterView } from '../../hooks/useCompactNav';
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
type PageRootProps = { type PageRootProps = {
nav: ReactNode; nav: ReactNode;
@@ -74,6 +75,7 @@ export function PageNav({
grow={isMobile ? 'Yes' : undefined} grow={isMobile ? 'Yes' : undefined}
className={classNames(css.PageNav({ size }), className)} className={classNames(css.PageNav({ size }), className)}
shrink={isMobile ? 'Yes' : 'No'} shrink={isMobile ? 'Yes' : 'No'}
data-page-nav=""
> >
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
{children} {children}
@@ -89,6 +91,7 @@ export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
className={classNames(css.PageNavHeader({ outlined }), className)} className={classNames(css.PageNavHeader({ outlined }), className)}
variant="Background" variant="Background"
size="600" size="600"
data-folder-tab="nav"
{...props} {...props}
ref={ref} ref={ref}
/> />
@@ -104,13 +107,16 @@ export function PageNavContent({
scrollRef?: MutableRefObject<HTMLDivElement | null>; scrollRef?: MutableRefObject<HTMLDivElement | null>;
scrollProps?: React.ComponentProps<typeof Scroll>; scrollProps?: React.ComponentProps<typeof Scroll>;
}) { }) {
const theme = useTheme();
const hideScrollbar = isStationeryTheme(theme);
return ( return (
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
<Scroll <Scroll
ref={scrollRef} ref={scrollRef}
variant="Background" variant="Background"
direction="Vertical" direction="Vertical"
size="300" size={hideScrollbar ? '0' : '300'}
hideTrack hideTrack
visibility="Hover" visibility="Hover"
{...scrollProps} {...scrollProps}
@@ -126,6 +132,7 @@ export const Page = as<'div'>(({ className, ...props }, ref) => (
grow="Yes" grow="Yes"
direction="Column" direction="Column"
className={classNames(ContainerColor({ variant: 'Surface' }), className)} className={classNames(ContainerColor({ variant: 'Surface' }), className)}
data-page=""
{...props} {...props}
ref={ref} ref={ref}
/> />
@@ -137,6 +144,7 @@ export const PageHeader = as<'div', css.PageHeaderVariants>(
as="header" as="header"
size="600" size="600"
className={classNames(css.PageHeader({ balance, outlined }), className)} className={classNames(css.PageHeader({ balance, outlined }), className)}
data-folder-tab-bar=""
{...props} {...props}
ref={ref} 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

@@ -14,7 +14,7 @@ import { RoomTimeline } from './RoomTimeline';
import { RoomViewTyping } from './RoomViewTyping'; import { RoomViewTyping } from './RoomViewTyping';
import { RoomTombstone } from './RoomTombstone'; import { RoomTombstone } from './RoomTombstone';
import { RoomInput } from './RoomInput'; import { RoomInput } from './RoomInput';
import { RoomViewFollowing } from './RoomViewFollowing'; import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
import { Page } from '../../components/page'; import { Page } from '../../components/page';
import { RoomViewHeader } from './RoomViewHeader'; import { RoomViewHeader } from './RoomViewHeader';
import { useKeyDown } from '../../hooks/useKeyDown'; import { useKeyDown } from '../../hooks/useKeyDown';
@@ -65,7 +65,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
const roomViewRef = useRef<HTMLDivElement>(null); const roomViewRef = useRef<HTMLDivElement>(null);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const { isLandscape, keyboardOpen } = useMobileKeyboardLayout(); const { immersive } = useMobileKeyboardLayout();
const { roomId } = room; const { roomId } = room;
const editor = useEditor(); const editor = useEditor();
@@ -104,9 +104,9 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
) )
); );
const flushComposer = keyboardOpen || isLandscape; const flushComposer = immersive;
const composerPadding = flushComposer ? '0' : `0 ${config.space.S400}`; const composerPadding = flushComposer ? '0' : `0 ${config.space.S400}`;
const showHeader = !keyboardOpen; const showHeader = !immersive;
return ( return (
<Page ref={roomViewRef}> <Page ref={roomViewRef}>
@@ -134,7 +134,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
data-composer-flush={flushComposer ? 'true' : undefined} data-composer-flush={flushComposer ? 'true' : undefined}
className={composerCss.ComposerDock} className={composerCss.ComposerDock}
> >
{!hideActivity && ( {immersive && !hideActivity && (
<div className={composerCss.FollowingFloat}> <div className={composerCss.FollowingFloat}>
<div className={composerCss.FollowingFloatHit}> <div className={composerCss.FollowingFloatHit}>
<RoomViewFollowing room={room} /> <RoomViewFollowing room={room} />
@@ -171,6 +171,12 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
</> </>
)} )}
</div> </div>
{!immersive &&
(hideActivity ? (
<RoomViewFollowingPlaceholder />
) : (
<RoomViewFollowing room={room} />
))}
</Box> </Box>
</> </>
)} )}

View File

@@ -1,12 +1,11 @@
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes'; import { recipe } from '@vanilla-extract/recipes';
import { DefaultReset, color, config } from 'folds'; import { DefaultReset, color, config, toRem } from 'folds';
export const RoomViewFollowingPlaceholder = style([ export const RoomViewFollowingPlaceholder = style([
DefaultReset, DefaultReset,
{ {
display: 'none', height: toRem(28),
height: 0,
}, },
]); ]);
@@ -14,13 +13,22 @@ export const RoomViewFollowing = recipe({
base: [ base: [
DefaultReset, DefaultReset,
{ {
minHeight: 0, minHeight: toRem(28),
padding: `${config.space.S100} ${config.space.S200}`, padding: `0 ${config.space.S400}`,
width: 'auto', width: '100%',
maxWidth: '100%', backgroundColor: color.Surface.Container,
backgroundColor: 'transparent',
color: color.Surface.OnContainer, color: color.Surface.OnContainer,
outline: 'none', 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: { variants: {

View File

@@ -802,8 +802,9 @@ export const Message = as<'div', MessageProps>(
gap="300" gap="300"
direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'} direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'}
justifyContent="SpaceBetween" justifyContent="SpaceBetween"
alignItems="Baseline" alignItems="Center"
grow="Yes" grow="Yes"
data-message-header=""
> >
<Box alignItems="Center" gap="200"> <Box alignItems="Center" gap="200">
<Username <Username

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

@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { isCapacitorNative } from '../utils/tauri';
const KEYBOARD_HEIGHT_THRESHOLD_PX = 120; const KEYBOARD_HEIGHT_THRESHOLD_PX = 120;
const IMMERSIVE_CLASS = 'immersive-landscape-keyboard';
const getIsLandscape = (): boolean => { const getIsLandscape = (): boolean => {
if (typeof window === 'undefined') return false; if (typeof window === 'undefined') return false;
@@ -24,9 +26,25 @@ const getKeyboardOpen = (): boolean => {
return tag === 'INPUT' || tag === 'TEXTAREA'; 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 = { export type MobileKeyboardLayout = {
isLandscape: boolean; isLandscape: boolean;
keyboardOpen: boolean; keyboardOpen: boolean;
/** Landscape + keyboard: compact immersive room chrome. */
immersive: boolean;
}; };
/** /**
@@ -37,6 +55,8 @@ export const useMobileKeyboardLayout = (): MobileKeyboardLayout => {
const [isLandscape, setIsLandscape] = useState(getIsLandscape); const [isLandscape, setIsLandscape] = useState(getIsLandscape);
const [keyboardOpen, setKeyboardOpen] = useState(getKeyboardOpen); const [keyboardOpen, setKeyboardOpen] = useState(getKeyboardOpen);
const immersive = isLandscape && keyboardOpen;
useEffect(() => { useEffect(() => {
const sync = () => { const sync = () => {
setIsLandscape(getIsLandscape()); setIsLandscape(getIsLandscape());
@@ -63,5 +83,15 @@ export const useMobileKeyboardLayout = (): MobileKeyboardLayout => {
}; };
}, []); }, []);
return { isLandscape, keyboardOpen }; 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

@@ -9,11 +9,11 @@ type ClientLayoutProps = {
}; };
export function ClientLayout({ nav, children }: ClientLayoutProps) { export function ClientLayout({ nav, children }: ClientLayoutProps) {
const { keyboardOpen } = useMobileKeyboardLayout(); const { immersive } = useMobileKeyboardLayout();
return ( return (
<Box grow="Yes"> <Box grow="Yes">
{!keyboardOpen && <Box shrink="No">{nav}</Box>} {!immersive && <Box shrink="No">{nav}</Box>}
<Box grow="Yes">{children}</Box> <Box grow="Yes">{children}</Box>
<DockedCallPanel /> <DockedCallPanel />
</Box> </Box>

View File

@@ -1,8 +1,7 @@
import { useAtomValue } from 'jotai'; import { useAtomValue, useStore } from 'jotai';
import { useAtom, useSetAtom } from 'jotai';
import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react'; import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import { RoomEvent, RoomEventHandlerMap, MatrixClient, MatrixEvent, MatrixEventEvent } from 'matrix-js-sdk';
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread'; import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
import LogoSVG from '../../../../public/res/svg/paarrot.svg'; import LogoSVG from '../../../../public/res/svg/paarrot.svg';
import LogoUnreadSVG from '../../../../public/res/svg/paarrot-unread.svg'; import LogoUnreadSVG from '../../../../public/res/svg/paarrot-unread.svg';
@@ -16,6 +15,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getDirectRoomPath, getHomeRoomPath, getSpaceRoomPath, getInboxInvitesPath } from '../pathUtils'; import { getDirectRoomPath, getHomeRoomPath, getSpaceRoomPath, getInboxInvitesPath } from '../pathUtils';
import { import {
getMemberDisplayName, getMemberDisplayName,
getMemberAvatarMxc,
getNotificationType, getNotificationType,
getUnreadInfo, getUnreadInfo,
isNotificationEvent, isNotificationEvent,
@@ -23,13 +23,18 @@ import {
guessPerfectParent, guessPerfectParent,
} from '../../utils/room'; } from '../../utils/room';
import { NotificationType, UnreadInfo } from '../../../types/matrix/room'; import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
import { getMxIdLocalPart, mxcUrlToHttp, getCanonicalAliasOrRoomId } from '../../utils/matrix'; import {
getMxIdLocalPart,
mxcUrlToHttp,
getCanonicalAliasOrRoomId,
encryptFile,
downloadMedia,
} from '../../utils/matrix';
import { mDirectAtom } from '../../state/mDirectList'; import { mDirectAtom } from '../../state/mDirectList';
import { roomToParentsAtom } from '../../state/room/roomToParents'; import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { plainToEditorInput, toPlainText } from '../../components/editor';
import { ShareRoomPicker } from '../../features/ShareRoomPicker'; import { ShareRoomPicker } from '../../features/ShareRoomPicker';
import { import {
isTauri, isTauri,
@@ -37,19 +42,24 @@ import {
isCapacitorNative, isCapacitorNative,
sendNotification, sendNotification,
setupNotificationTapListener, setupNotificationTapListener,
clearNotificationsForRoom,
NOTIF_GROUP_DIRECTS,
NOTIF_GROUP_HOME,
type NotificationGroupInfo,
} from '../../utils/tauri'; } from '../../utils/tauri';
import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api'; import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api';
import { import {
startBackgroundSync, startBackgroundSync,
stopBackgroundSync, stopBackgroundSync,
setAppForegroundState, setAppForegroundState,
syncNotificationGroupMap,
} from '../../utils/backgroundSync'; } from '../../utils/backgroundSync';
import { import {
TUploadItem, TUploadItem,
roomIdToMsgDraftAtomFamily,
roomIdToUploadItemsAtomFamily, roomIdToUploadItemsAtomFamily,
} from '../../state/room/roomInputDrafts'; } from '../../state/room/roomInputDrafts';
import { encryptFile } from '../../utils/matrix'; import { fulfilledPromiseSettledResult } from '../../utils/common';
import { safeFile } from '../../utils/mimeTypes';
import { import {
AndroidSharePayload, AndroidSharePayload,
clearPendingAndroidShare, clearPendingAndroidShare,
@@ -59,6 +69,92 @@ import {
materializeSharedFile, materializeSharedFile,
} from '../../utils/androidShare'; } from '../../utils/androidShare';
/** Wait briefly for an encrypted event to decrypt before reading its body. */
async function waitForEventDecryption(mx: MatrixClient, mEvent: MatrixEvent) {
if (!mEvent.isEncrypted() || mEvent.getClearContent() || mEvent.isDecryptionFailure()) {
return;
}
const crypto = mx.getCrypto();
if (crypto) {
try {
await mEvent.attemptDecryption(crypto as any, { isRetry: true });
} catch {
// Decryption may complete asynchronously via the Decrypted listener below.
}
}
if (mEvent.getClearContent() || mEvent.isDecryptionFailure()) return;
await new Promise<void>((resolve) => {
const timeout = window.setTimeout(() => {
mEvent.removeListener(MatrixEventEvent.Decrypted, onDecrypted);
resolve();
}, 2000);
const onDecrypted = () => {
window.clearTimeout(timeout);
mEvent.removeListener(MatrixEventEvent.Decrypted, onDecrypted);
resolve();
};
mEvent.once(MatrixEventEvent.Decrypted, onDecrypted);
});
}
/** Build a human-readable notification body from (possibly decrypted) event content. */
function notificationBodyFromEvent(mEvent: MatrixEvent): string | undefined {
const content = mEvent.getClearContent() ?? mEvent.getContent();
const eventType = mEvent.getType();
if (eventType === 'm.reaction') {
const reactionKey = content['m.relates_to']?.key;
return reactionKey ? `reacted with ${reactionKey}` : 'reacted to a message';
}
if (eventType === 'm.room.encrypted' || mEvent.isDecryptionFailure()) {
return 'Encrypted message';
}
const rawBody = typeof content.body === 'string' ? content.body : undefined;
switch (content.msgtype) {
case 'm.image':
return rawBody ? `📷 ${rawBody}` : '📷 Photo';
case 'm.video':
return rawBody ? `🎥 ${rawBody}` : '🎥 Video';
case 'm.audio':
return rawBody ? `🎵 ${rawBody}` : '🎵 Audio';
case 'm.file':
return rawBody ? `📎 ${rawBody}` : '📎 File';
case 'm.sticker':
return rawBody ? `🖼️ ${rawBody}` : '🖼️ Sticker';
default:
return rawBody;
}
}
/** Download an authenticated media URL to a base64 string for native notification icons. */
async function mediaUrlToBase64(
url: string,
accessToken: string | null | undefined
): Promise<string | undefined> {
try {
const blob = await downloadMedia(url, accessToken);
const buffer = await blob.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
const mime = blob.type || 'image/jpeg';
return `data:${mime};base64,${btoa(binary)}`;
} catch (err) {
console.warn('[Notifications] Failed to fetch avatar for notification icon:', err);
return undefined;
}
}
/** /**
* Applies the selected emoji style font to the document. * Applies the selected emoji style font to the document.
* - System: Uses the native OS emoji font * - System: Uses the native OS emoji font
@@ -228,10 +324,25 @@ function MessageNotifications() {
}); });
}, [navigate]); }, [navigate]);
const roomToUnread = useAtomValue(roomToUnreadAtom);
const previousUnreadRoomsRef = useRef<Set<string>>(new Set());
// Dismiss OS notifications when a room's unread is cleared (local read or receipt).
useEffect(() => {
const currentRooms = new Set(roomToUnread.keys());
for (const roomId of previousUnreadRoomsRef.current) {
if (!currentRooms.has(roomId)) {
void clearNotificationsForRoom(roomId);
}
}
previousUnreadRoomsRef.current = currentRooms;
}, [roomToUnread]);
const notify = useCallback( const notify = useCallback(
({ ({
roomName, roomName,
roomAvatar, roomAvatar,
iconBase64,
username, username,
messageBody, messageBody,
roomId, roomId,
@@ -240,14 +351,49 @@ function MessageNotifications() {
}: { }: {
roomName: string; roomName: string;
roomAvatar?: string; roomAvatar?: string;
iconBase64?: string;
username: string; username: string;
messageBody?: string; messageBody?: string;
roomId: string; roomId: string;
eventId: string; eventId: string;
isDm: boolean; isDm: boolean;
}) => { }) => {
const notificationTitle = username; let group: NotificationGroupInfo;
const notificationBody = messageBody || 'New message'; if (isDm) {
group = {
groupId: NOTIF_GROUP_DIRECTS,
groupName: 'Direct messages',
kind: 'direct',
roomName,
};
} else {
const orphanParents = getOrphanParents(roomToParents, roomId);
if (orphanParents.length > 0 && mx) {
const parentSpace = guessPerfectParent(mx, roomId, orphanParents) ?? orphanParents[0];
const spaceRoom = mx.getRoom(parentSpace);
group = {
groupId: parentSpace,
groupName: spaceRoom?.name || 'Space',
kind: 'space',
roomName,
};
} else {
group = {
groupId: NOTIF_GROUP_HOME,
groupName: 'Home',
kind: 'home',
roomName,
};
}
}
// DMs: sender as title. Rooms: room name as title, sender in body.
const notificationTitle = isDm ? username : roomName || username;
const notificationBody = isDm
? messageBody || 'New message'
: messageBody
? `${username}: ${messageBody}`
: `${username} sent a message`;
/** Replicates TitleBar click navigation logic */ /** Replicates TitleBar click navigation logic */
const navigateToRoom = () => { const navigateToRoom = () => {
@@ -293,6 +439,10 @@ function MessageNotifications() {
title: notificationTitle, title: notificationTitle,
body: notificationBody, body: notificationBody,
path: roomPath, path: roomPath,
roomId,
group,
icon: roomAvatar,
iconBase64,
onClick: () => { onClick: () => {
if (!window.closed) navigate(roomPath); if (!window.closed) navigate(roomPath);
}, },
@@ -378,39 +528,47 @@ function MessageNotifications() {
return; return;
} }
// Match TitleBar / room bell settings: Default and Mentions rooms only notify on highlights.
const notificationType = getNotificationType(mx, room.roomId);
const isDm = mDirects.has(room.roomId);
const mentionsOnly =
notificationType === NotificationType.MentionsAndKeywords ||
(notificationType === NotificationType.Default && !isDm);
if (mentionsOnly && unreadInfo.highlight === 0) return;
if ( if (
showNotifications && showNotifications &&
((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted')) ((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
) { ) {
const avatarMxc = void (async () => {
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); await waitForEventDecryption(mx, mEvent);
const content = mEvent.getContent();
let messageBody: string | undefined; const avatarMxc =
if (mEvent.getType() === 'm.reaction') { getMemberAvatarMxc(room, sender) ??
// For reactions, show "reacted with {emoji}" room.getAvatarFallbackMember()?.getMxcAvatarUrl() ??
const reactionKey = content['m.relates_to']?.key; room.getMxcAvatarUrl();
if (reactionKey) { const roomAvatar = avatarMxc
messageBody = `reacted with ${reactionKey}`;
} else {
messageBody = 'reacted to a message';
}
} else {
messageBody = typeof content.body === 'string' ? content.body : undefined;
}
const isDm = room.getJoinedMemberCount() === 2 && !room.isSpaceRoom();
notify({
roomName: room.name ?? 'Unknown',
roomAvatar: avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined, : undefined;
username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender,
messageBody, let iconBase64: string | undefined;
roomId: room.roomId, if (roomAvatar && isCapacitorNative()) {
eventId, iconBase64 = await mediaUrlToBase64(roomAvatar, mx.getAccessToken());
isDm, }
});
const messageBody = notificationBodyFromEvent(mEvent);
notify({
roomName: room.name ?? 'Unknown',
roomAvatar,
iconBase64,
username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender,
messageBody,
roomId: room.roomId,
eventId,
isDm,
});
})();
} }
if (notificationSound) { if (notificationSound) {
@@ -430,6 +588,7 @@ function MessageNotifications() {
notify, notify,
selectedRoomId, selectedRoomId,
useAuthentication, useAuthentication,
mDirects,
]); ]);
return null; return null;
@@ -443,6 +602,9 @@ function MessageNotifications() {
*/ */
function BackgroundSyncSetup() { function BackgroundSyncSetup() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const mDirects = useAtomValue(mDirectAtom);
const roomToParents = useAtomValue(roomToParentsAtom);
// Try to make a simple fetch to verify component is rendering // Try to make a simple fetch to verify component is rendering
try { try {
fetch('/_matrix/client/v3/sync', { method: 'HEAD' }).catch(() => {}); fetch('/_matrix/client/v3/sync', { method: 'HEAD' }).catch(() => {});
@@ -462,6 +624,49 @@ function BackgroundSyncSetup() {
}; };
}, [mx]); }, [mx]);
// Keep native background notifier informed of space → room grouping.
useEffect(() => {
const rooms: Record<
string,
{ groupId: string; groupName: string; roomName: string; kind: 'direct' | 'space' | 'home' }
> = {};
for (const room of mx.getRooms()) {
if (room.isSpaceRoom()) continue;
const roomId = room.roomId;
const roomName = room.name || roomId;
if (mDirects.has(roomId)) {
rooms[roomId] = {
groupId: NOTIF_GROUP_DIRECTS,
groupName: 'Direct messages',
roomName,
kind: 'direct',
};
continue;
}
const orphanParents = getOrphanParents(roomToParents, roomId);
if (orphanParents.length > 0) {
const parentSpace = guessPerfectParent(mx, roomId, orphanParents) ?? orphanParents[0];
const spaceRoom = mx.getRoom(parentSpace);
rooms[roomId] = {
groupId: parentSpace,
groupName: spaceRoom?.name || 'Space',
roomName,
kind: 'space',
};
} else {
rooms[roomId] = {
groupId: NOTIF_GROUP_HOME,
groupName: 'Home',
roomName,
kind: 'home',
};
}
}
void syncNotificationGroupMap(rooms);
}, [mx, mDirects, roomToParents]);
return null; return null;
} }
@@ -517,10 +722,71 @@ function TaskbarFlashStopper() {
function AndroidShareIntentHandler() { function AndroidShareIntentHandler() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const store = useStore();
const navigate = useNavigate(); const navigate = useNavigate();
const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null); const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null);
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
const roomToParents = useAtomValue(roomToParentsAtom); 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( const applyPendingShare = useCallback(
async (share: AndroidSharePayload, roomId: string) => { async (share: AndroidSharePayload, roomId: string) => {
@@ -594,6 +860,22 @@ function AndroidShareIntentHandler() {
[mx] [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( const handlePickRoom = useCallback(
(roomId: string) => { (roomId: string) => {
if (!pendingShare) return; if (!pendingShare) return;
@@ -640,7 +922,7 @@ function AndroidShareIntentHandler() {
getPendingAndroidShare() getPendingAndroidShare()
.then((share) => { .then((share) => {
if (mounted && share) { if (mounted && share) {
setPendingShare(share); ingestShare(share);
} }
}) })
.catch((err) => { .catch((err) => {
@@ -649,7 +931,7 @@ function AndroidShareIntentHandler() {
listenForAndroidShares((share) => { listenForAndroidShares((share) => {
if (mounted) { if (mounted) {
setPendingShare(share); ingestShare(share);
} }
}) })
.then((handle) => { .then((handle) => {
@@ -663,7 +945,7 @@ function AndroidShareIntentHandler() {
mounted = false; mounted = false;
void listenerHandle?.remove(); void listenerHandle?.remove();
}; };
}, []); }, [ingestShare]);
if (!pendingShare) return null; if (!pendingShare) return null;

View File

@@ -10,6 +10,10 @@ type UnifiedPushStatus = {
distributors: string[] | string; distributors: string[] | string;
}; };
type ClearRoomNotificationsResult = {
cleared: boolean;
};
type UnifiedPushEndpointEvent = { type UnifiedPushEndpointEvent = {
endpoint: string; endpoint: string;
previousEndpoint: string; previousEndpoint: string;
@@ -48,6 +52,28 @@ interface MatrixBackgroundSyncPlugin {
setAppForeground(options: { foreground: boolean }): Promise<void>; setAppForeground(options: { foreground: boolean }): Promise<void>;
/** Returns fetch state and current UnifiedPush registration details. */ /** Returns fetch state and current UnifiedPush registration details. */
getStatus(): Promise<UnifiedPushStatus>; getStatus(): Promise<UnifiedPushStatus>;
/** Cancel native tray notifications posted for a Matrix room. */
clearRoomNotifications(options: { roomId: string }): Promise<ClearRoomNotificationsResult>;
/**
* Persist room → space/group metadata so background notifications can nest
* under Direct messages / Space name / Home.
*/
setNotificationGroups(options: {
rooms: Record<
string,
{ groupId: string; groupName: string; roomName: string; kind: string }
>;
}): Promise<{ success: boolean }>;
/** Post a message notification with optional avatar (base64) from the JS layer. */
showNotification(options: {
title: string;
body: string;
roomId: string;
groupId: string;
groupName: string;
kind: string;
largeIconBase64?: string;
}): Promise<{ shown: boolean }>;
addListener( addListener(
eventName: 'unifiedPushNewEndpoint', eventName: 'unifiedPushNewEndpoint',
listenerFunc: (event: UnifiedPushEndpointEvent) => void listenerFunc: (event: UnifiedPushEndpointEvent) => void
@@ -516,3 +542,59 @@ export const getBackgroundSyncStatus = async (): Promise<UnifiedPushStatus | und
return undefined; return undefined;
} }
}; };
/**
* Clears native (MatrixSyncService) tray notifications for a room after it is read.
* No-op when background sync / native Android is unavailable.
*/
export const clearNativeRoomNotifications = async (roomId: string): Promise<void> => {
if (!isBackgroundSyncSupported() || !roomId) return;
try {
await MatrixBackgroundSync.clearRoomNotifications({ roomId });
} catch (err) {
console.warn('[BackgroundSync] clearRoomNotifications failed:', err);
}
};
/**
* Syncs space/DM grouping metadata to the native layer for background notifications.
*/
export const syncNotificationGroupMap = async (
rooms: Record<
string,
{ groupId: string; groupName: string; roomName: string; kind: string }
>
): Promise<void> => {
if (!isBackgroundSyncSupported()) return;
try {
await MatrixBackgroundSync.setNotificationGroups({ rooms });
} catch (err) {
console.warn('[BackgroundSync] setNotificationGroups failed:', err);
}
};
/**
* Posts a native tray notification (supports dynamic/authenticated avatar icons).
* Prefer this over Capacitor LocalNotifications on Android.
*/
export const showNativeNotification = async (options: {
title: string;
body: string;
roomId: string;
groupId: string;
groupName: string;
kind: string;
largeIconBase64?: string;
}): Promise<boolean> => {
if (!isBackgroundSyncSupported()) return false;
try {
await MatrixBackgroundSync.showNotification(options);
return true;
} catch (err) {
console.warn('[BackgroundSync] showNotification failed:', err);
return false;
}
};

View File

@@ -351,24 +351,7 @@ const ensureNotificationChannel = async (): Promise<void> => {
}; };
const ensureCapacitorNotificationChannel = async (): Promise<void> => { const ensureCapacitorNotificationChannel = async (): Promise<void> => {
if (notificationChannelCreated || !isAndroid()) return; await ensureCapacitorNotificationChannels();
try {
const { LocalNotifications } = await import('@capacitor/local-notifications');
await LocalNotifications.createChannel({
id: 'messages',
name: 'Messages',
description: 'Message notifications from Matrix',
importance: 5,
sound: 'default',
visibility: 1,
vibration: true,
lights: true,
});
notificationChannelCreated = true;
} catch (err) {
console.warn('Failed to create Capacitor notification channel:', err);
}
}; };
/** /**
@@ -422,6 +405,225 @@ export const getSystemNotificationPermissionState = async (): Promise<Permission
return 'denied'; return 'denied';
}; };
/** Foreground sync status notification — must not collide with room message IDs. */
const ANDROID_STATUS_NOTIFICATION_ID = 1001;
/** Android notification group keys for rooms outside a space. */
export const NOTIF_GROUP_DIRECTS = 'paarrot_directs';
export const NOTIF_GROUP_HOME = 'paarrot_home';
/** Capacitor / native channel ids (under the Paarrot app). */
export const NOTIF_CHANNEL_DIRECTS = 'messages_directs';
export const NOTIF_CHANNEL_SPACES = 'messages_spaces';
export const NOTIF_CHANNEL_HOME = 'messages_home';
/** Legacy channel — kept so older installs still receive. */
export const NOTIF_CHANNEL_MESSAGES_LEGACY = 'messages';
export type NotificationGroupKind = 'direct' | 'space' | 'home';
export type NotificationGroupInfo = {
groupId: string;
groupName: string;
kind: NotificationGroupKind;
roomName?: string;
};
/**
* Stable Android notification id for a Matrix room.
* Re-using the same id replaces the previous tray entry instead of stacking.
*/
export const notificationIdForRoom = (roomId: string): number => {
let hash = 0;
for (let i = 0; i < roomId.length; i += 1) {
hash = (hash << 5) - hash + roomId.charCodeAt(i);
hash |= 0;
}
let id = Math.abs(hash) % 2147483647;
if (id === 0 || id === ANDROID_STATUS_NOTIFICATION_ID) {
id = ANDROID_STATUS_NOTIFICATION_ID + 1;
}
return id;
};
/** Stable id for a space/group summary notification. */
export const notificationIdForGroupSummary = (groupId: string): number =>
notificationIdForRoom(`summary:${groupId}`);
const channelIdForKind = (kind: NotificationGroupKind): string => {
switch (kind) {
case 'direct':
return NOTIF_CHANNEL_DIRECTS;
case 'space':
return NOTIF_CHANNEL_SPACES;
default:
return NOTIF_CHANNEL_HOME;
}
};
const ensureCapacitorNotificationChannels = async (): Promise<void> => {
if (!isAndroid()) return;
try {
const { LocalNotifications } = await import('@capacitor/local-notifications');
const channels: Array<{
id: string;
name: string;
description: string;
}> = [
{
id: NOTIF_CHANNEL_DIRECTS,
name: 'Direct messages',
description: 'One-to-one Matrix conversations',
},
{
id: NOTIF_CHANNEL_SPACES,
name: 'Spaces',
description: 'Messages from rooms inside Spaces',
},
{
id: NOTIF_CHANNEL_HOME,
name: 'Other rooms',
description: 'Rooms that are not in a Space',
},
{
id: NOTIF_CHANNEL_MESSAGES_LEGACY,
name: 'Messages',
description: 'Legacy message channel',
},
];
await Promise.all(
channels.map((channel) =>
LocalNotifications.createChannel({
id: channel.id,
name: channel.name,
description: channel.description,
importance: 5,
sound: 'default',
visibility: 1,
vibration: true,
lights: true,
})
)
);
notificationChannelCreated = true;
} catch (err) {
console.warn('Failed to create Capacitor notification channels:', err);
}
};
const notificationBelongsToRoom = (
notification: { id?: number; extra?: Record<string, unknown> },
roomId: string
): boolean => {
if (notification.id === notificationIdForRoom(roomId)) return true;
const extra = notification.extra;
if (!extra || typeof extra !== 'object') return false;
if (extra.roomId === roomId) return true;
const path = extra.path;
if (typeof path === 'string') {
return path.includes(encodeURIComponent(roomId)) || path.includes(roomId);
}
return false;
};
const clearCapacitorGroupSummaryIfEmpty = async (
LocalNotifications: {
getDeliveredNotifications: () => Promise<{ notifications: Array<{ id?: number; extra?: Record<string, unknown>; group?: string }> }>;
cancel: (opts: { notifications: Array<{ id: number }> }) => Promise<void>;
removeDeliveredNotifications: (opts: { notifications: unknown[] }) => Promise<void>;
},
groupId: string
): Promise<void> => {
const summaryId = notificationIdForGroupSummary(groupId);
const delivered = await LocalNotifications.getDeliveredNotifications().catch(() => null);
if (!delivered?.notifications) return;
const siblings = delivered.notifications.filter((n) => {
if (n.id === summaryId) return false;
if (n.group === groupId) return true;
return n.extra?.groupId === groupId;
});
if (siblings.length > 0) return;
await LocalNotifications.cancel({ notifications: [{ id: summaryId }] }).catch(() => undefined);
const summary = delivered.notifications.find((n) => n.id === summaryId);
if (summary) {
await LocalNotifications.removeDeliveredNotifications({
notifications: [summary],
}).catch(() => undefined);
}
};
/**
* Dismiss system notifications tied to a room (after mark-as-read / receipt).
* Clears Capacitor LocalNotifications and native MatrixSyncService tray entries.
* Also drops the space/group summary when no child notifications remain.
*/
export const clearNotificationsForRoom = async (roomId: string): Promise<void> => {
if (!roomId) return;
if (isCapacitorNative()) {
try {
const { LocalNotifications } = await import('@capacitor/local-notifications');
const id = notificationIdForRoom(roomId);
const deliveredBefore = await LocalNotifications.getDeliveredNotifications().catch(() => null);
const roomNotif = deliveredBefore?.notifications?.find((n: { id?: number; extra?: Record<string, unknown> }) =>
notificationBelongsToRoom(n, roomId)
);
const groupId =
(typeof roomNotif?.extra?.groupId === 'string' && roomNotif.extra.groupId) ||
(typeof (roomNotif as { group?: string } | undefined)?.group === 'string'
? (roomNotif as { group?: string }).group
: undefined);
await LocalNotifications.cancel({ notifications: [{ id }] }).catch(() => undefined);
if (deliveredBefore?.notifications?.length) {
const matching = deliveredBefore.notifications.filter((n: { id?: number; extra?: Record<string, unknown> }) =>
notificationBelongsToRoom(n, roomId)
);
if (matching.length > 0) {
await LocalNotifications.removeDeliveredNotifications({
notifications: matching,
}).catch(() => undefined);
}
}
if (groupId) {
await clearCapacitorGroupSummaryIfEmpty(LocalNotifications, groupId);
}
} catch (err) {
console.warn('Failed to clear Capacitor notifications for room:', err);
}
try {
const { clearNativeRoomNotifications } = await import('./backgroundSync');
await clearNativeRoomNotifications(roomId);
} catch (err) {
console.warn('Failed to clear native room notifications:', err);
}
return;
}
if (isTauri() && !isElectron()) {
try {
const { cancel, removeActive } = await import('@tauri-apps/plugin-notification');
const id = notificationIdForRoom(roomId);
if (typeof cancel === 'function') {
await cancel([id]).catch(() => undefined);
}
if (typeof removeActive === 'function') {
await removeActive([{ id }]).catch(() => undefined);
}
} catch (err) {
console.warn('Failed to clear Tauri notifications for room:', err);
}
}
};
/** /**
* Send a notification using Tauri's notification plugin * Send a notification using Tauri's notification plugin
* Falls back to browser Notification API if not in Tauri * Falls back to browser Notification API if not in Tauri
@@ -430,10 +632,22 @@ export const sendNotification = async (options: {
title: string; title: string;
body: string; body: string;
icon?: string; icon?: string;
iconBase64?: string;
path?: string; path?: string;
roomId?: string;
group?: NotificationGroupInfo;
onClick?: () => void; onClick?: () => void;
}): Promise<void> => { }): Promise<void> => {
const { title, body, icon, path, onClick } = options; const { title, body, icon, iconBase64, path, roomId, group, onClick } = options;
const extra = {
...(path ? { path } : {}),
...(roomId ? { roomId } : {}),
...(group?.groupId ? { groupId: group.groupId, groupName: group.groupName, groupKind: group.kind } : {}),
};
const hasExtra = Object.keys(extra).length > 0;
const channelId = group ? channelIdForKind(group.kind) : NOTIF_CHANNEL_HOME;
const groupId = group?.groupId;
const groupName = group?.groupName;
// Use Electron's native notification API if in Electron // Use Electron's native notification API if in Electron
if (isElectron()) { if (isElectron()) {
@@ -475,11 +689,27 @@ export const sendNotification = async (options: {
await tauriSendNotification({ await tauriSendNotification({
title, title,
body, body,
// Stable per-room id replaces prior tray entry instead of stacking
id: roomId ? notificationIdForRoom(roomId) : undefined,
// Use the channel on Android // Use the channel on Android
channelId: isAndroid() ? 'messages' : undefined, channelId: isAndroid() ? channelId : undefined,
// Store path in extra data for notification tap handling group: groupId,
extra: path ? { path } : undefined, icon,
// Store path/roomId in extra data for tap handling + clear-on-read
extra: hasExtra ? extra : undefined,
}); });
if (isAndroid() && groupId && groupName) {
await tauriSendNotification({
title: groupName,
body: 'New messages',
id: notificationIdForGroupSummary(groupId),
channelId,
group: groupId,
groupSummary: true,
extra: { groupId, groupName, isGroupSummary: true },
}).catch(() => undefined);
}
} }
return; return;
} catch (err) { } catch (err) {
@@ -488,6 +718,26 @@ export const sendNotification = async (options: {
} }
if (isCapacitorNative()) { if (isCapacitorNative()) {
// Prefer native NotificationManager so we can set dynamic/authenticated avatars.
// Capacitor LocalNotifications only supports drawable resource largeIcon names.
if (roomId && groupId && groupName && group) {
try {
const { showNativeNotification } = await import('./backgroundSync');
const shown = await showNativeNotification({
title,
body,
roomId,
groupId,
groupName,
kind: group.kind,
largeIconBase64: iconBase64,
});
if (shown) return;
} catch (err) {
console.warn('Native showNotification failed, falling back to LocalNotifications:', err);
}
}
try { try {
const { LocalNotifications } = await import('@capacitor/local-notifications'); const { LocalNotifications } = await import('@capacitor/local-notifications');
let perm = await LocalNotifications.checkPermissions(); let perm = await LocalNotifications.checkPermissions();
@@ -496,21 +746,53 @@ export const sendNotification = async (options: {
} }
if (perm.display === 'granted') { if (perm.display === 'granted') {
await ensureCapacitorNotificationChannel(); await ensureCapacitorNotificationChannels();
// One notification per room: same id updates the existing tray entry.
const id = roomId
? notificationIdForRoom(roomId)
: Math.floor(Date.now() % 2147483647);
const roomNotification: Record<string, unknown> = {
id,
title,
body,
channelId: isAndroid() ? channelId : undefined,
smallIcon: isAndroid() ? ANDROID_NOTIFICATION_SMALL_ICON : undefined,
iconColor: isAndroid() ? ANDROID_NOTIFICATION_ICON_COLOR : undefined,
extra: hasExtra ? extra : undefined,
threadIdentifier: groupId,
};
if (isAndroid() && groupId) {
roomNotification.group = groupId;
if (groupName) {
roomNotification.summaryText = groupName;
}
if (group?.roomName && group.roomName !== title) {
roomNotification.largeBody = body;
}
}
const notifications: Array<Record<string, unknown>> = [roomNotification];
// Nest rooms under a space / Directs / Home summary in the shade.
if (isAndroid() && groupId && groupName) {
notifications.push({
id: notificationIdForGroupSummary(groupId),
title: groupName,
body: 'New messages',
channelId,
smallIcon: ANDROID_NOTIFICATION_SMALL_ICON,
iconColor: ANDROID_NOTIFICATION_ICON_COLOR,
group: groupId,
groupSummary: true,
extra: { groupId, groupName, isGroupSummary: true },
});
}
const id = Math.floor(Date.now() % 2147483647);
await LocalNotifications.schedule({ await LocalNotifications.schedule({
notifications: [ notifications: notifications as any,
{
id,
title,
body,
channelId: isAndroid() ? 'messages' : undefined,
smallIcon: isAndroid() ? ANDROID_NOTIFICATION_SMALL_ICON : undefined,
iconColor: isAndroid() ? ANDROID_NOTIFICATION_ICON_COLOR : undefined,
extra: path ? { path } : undefined,
},
],
}); });
} }
return; return;

View File

@@ -7,6 +7,11 @@ export const isCapacitorAndroid = Boolean(
cap?.isNativePlatform?.() && cap?.getPlatform?.() === 'android' 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) { if (isCapacitorAndroid) {
document.documentElement.classList.add('android-capacitor'); document.documentElement.classList.add('android-capacitor');
document.documentElement.style.setProperty( 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", "name": "paarrot",
"version": "4.11.77", "version": "4.11.121",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paarrot", "name": "paarrot",
"version": "4.11.77", "version": "4.11.121",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@capacitor/android": "8.3.0", "@capacitor/android": "8.3.0",
"@capacitor/cli": "8.3.0", "@capacitor/cli": "8.3.0",
"@capacitor/core": "8.3.0", "@capacitor/core": "8.3.0",
"@capacitor/local-notifications": "8.0.2" "@capacitor/local-notifications": "8.0.2",
"@capacitor/status-bar": "8.0.1"
}, },
"devDependencies": { "devDependencies": {
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
@@ -82,6 +83,15 @@
"@capacitor/core": ">=8.0.0" "@capacitor/core": ">=8.0.0"
} }
}, },
"node_modules/@capacitor/status-bar": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.1.tgz",
"integrity": "sha512-OR59dlbwvmrV5dKsC9lvwv48QaGbqcbSTBpk+9/WXWxXYSdXXdzJZU9p8oyNPAkuJhCdnSa3XmU43fZRPBJJ5w==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",

View File

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