Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3940441d9b | ||
| cfabcc5fbf | |||
|
|
a40319e8c8 | ||
| 45bc475340 | |||
|
|
60edb063dd | ||
| 0c85c36838 | |||
|
|
d59646c630 | ||
| c1985eda15 | |||
|
|
2e018b0367 | ||
| 8046474b8a | |||
|
|
9821b16968 | ||
| a887cd39f1 |
@@ -102,29 +102,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Android APK
|
- name: Build Android APK
|
||||||
run: npm run android:apk:linux
|
run: npm run android:apk:linux
|
||||||
|
|
||||||
- name: Upload APK
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: Paarrot-Android.apk
|
|
||||||
path: android/app/build/outputs/apk/debug/Paarrot-*.apk
|
|
||||||
|
|
||||||
create-release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [increment-version, build-android]
|
|
||||||
if: always() && needs.build-android.result == 'success'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
ref: ${{ github.ref_name }}
|
|
||||||
|
|
||||||
- name: Pull latest (after version bump)
|
|
||||||
if: github.ref == 'refs/heads/master'
|
|
||||||
run: git pull origin master
|
|
||||||
|
|
||||||
- name: Get version
|
- name: Get version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
@@ -135,7 +113,14 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "Version: $VERSION"
|
echo "Version: $VERSION"
|
||||||
|
|
||||||
|
- name: Prepare release files
|
||||||
|
run: |
|
||||||
|
mkdir -p release-files
|
||||||
|
find android/app/build/outputs/apk/debug -type f -name "Paarrot-*.apk" -exec cp {} release-files/ \;
|
||||||
|
ls -la release-files/
|
||||||
|
test -n "$(ls -A release-files/*.apk 2>/dev/null)"
|
||||||
|
|
||||||
- name: Check if tag exists
|
- name: Check if tag exists
|
||||||
id: tag_check
|
id: tag_check
|
||||||
run: |
|
run: |
|
||||||
@@ -147,7 +132,7 @@ jobs:
|
|||||||
echo "Tag v$VERSION does not exist"
|
echo "Tag v$VERSION does not exist"
|
||||||
echo "exists=false" >> $GITHUB_OUTPUT
|
echo "exists=false" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Create and push tag
|
- name: Create and push tag
|
||||||
if: steps.tag_check.outputs.exists == 'false'
|
if: steps.tag_check.outputs.exists == 'false'
|
||||||
run: |
|
run: |
|
||||||
@@ -155,18 +140,9 @@ jobs:
|
|||||||
git config user.email "actions@github.com"
|
git config user.email "actions@github.com"
|
||||||
git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}"
|
git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}"
|
||||||
git push origin "v${{ steps.version.outputs.VERSION }}"
|
git push origin "v${{ steps.version.outputs.VERSION }}"
|
||||||
|
|
||||||
- name: Download all artifacts
|
# Upload via the internal Gitea API. actions/upload-artifact times out against
|
||||||
uses: actions/download-artifact@v3
|
# git.ruv.wtf:443 from this runner; synbox:8418 is reachable on the LAN.
|
||||||
with:
|
|
||||||
path: artifacts
|
|
||||||
|
|
||||||
- name: Prepare release files
|
|
||||||
run: |
|
|
||||||
mkdir -p release-files
|
|
||||||
find artifacts -type f -name "*.apk" -exec cp {} release-files/ \;
|
|
||||||
ls -la release-files/
|
|
||||||
|
|
||||||
- name: Create or Update Gitea Release
|
- name: Create or Update Gitea Release
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -175,10 +151,10 @@ jobs:
|
|||||||
TAG_NAME="v${VERSION}"
|
TAG_NAME="v${VERSION}"
|
||||||
API_BASE="http://synbox.ruv.wtf:8418/api/v1"
|
API_BASE="http://synbox.ruv.wtf:8418/api/v1"
|
||||||
REPO="litruv/cinny-mobile"
|
REPO="litruv/cinny-mobile"
|
||||||
|
|
||||||
RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
"${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
|
"${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
|
||||||
|
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
echo "Creating new release for ${TAG_NAME}..."
|
echo "Creating new release for ${TAG_NAME}..."
|
||||||
RELEASE_ID=$(curl -s -X POST \
|
RELEASE_ID=$(curl -s -X POST \
|
||||||
@@ -198,7 +174,7 @@ jobs:
|
|||||||
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Uploading assets..."
|
echo "Uploading assets..."
|
||||||
for FILE in release-files/*; do
|
for FILE in release-files/*; do
|
||||||
FILENAME="Paarrot-${VERSION}.apk"
|
FILENAME="Paarrot-${VERSION}.apk"
|
||||||
@@ -210,9 +186,9 @@ jobs:
|
|||||||
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
|
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
|
||||||
echo ""
|
echo ""
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "Gitea release complete!"
|
echo "Gitea release complete!"
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GHTOKEN }}
|
GH_TOKEN: ${{ secrets.GHTOKEN }}
|
||||||
@@ -220,10 +196,10 @@ jobs:
|
|||||||
VERSION="${{ steps.version.outputs.VERSION }}"
|
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||||
TAG_NAME="v${VERSION}"
|
TAG_NAME="v${VERSION}"
|
||||||
GITHUB_REPO="Paarrot/Paarrot-Mobile"
|
GITHUB_REPO="Paarrot/Paarrot-Mobile"
|
||||||
|
|
||||||
RELEASE_ID=$(curl -s -H "Authorization: token ${GH_TOKEN}" \
|
RELEASE_ID=$(curl -s -H "Authorization: token ${GH_TOKEN}" \
|
||||||
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
|
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
|
||||||
|
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
echo "Creating new GitHub release for ${TAG_NAME}..."
|
echo "Creating new GitHub release for ${TAG_NAME}..."
|
||||||
RELEASE_ID=$(curl -s -X POST \
|
RELEASE_ID=$(curl -s -X POST \
|
||||||
@@ -235,11 +211,11 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "GitHub release exists with ID: ${RELEASE_ID}"
|
echo "GitHub release exists with ID: ${RELEASE_ID}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Uploading assets to GitHub..."
|
echo "Uploading assets to GitHub..."
|
||||||
UPLOAD_URL=$(curl -s -H "Authorization: token ${GH_TOKEN}" \
|
UPLOAD_URL=$(curl -s -H "Authorization: token ${GH_TOKEN}" \
|
||||||
"https://api.github.com/repos/${GITHUB_REPO}/releases/${RELEASE_ID}" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
"https://api.github.com/repos/${GITHUB_REPO}/releases/${RELEASE_ID}" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
||||||
|
|
||||||
for FILE in release-files/*; do
|
for FILE in release-files/*; do
|
||||||
FILENAME="Paarrot-${VERSION}.apk"
|
FILENAME="Paarrot-${VERSION}.apk"
|
||||||
echo "Uploading ${FILENAME} to GitHub..."
|
echo "Uploading ${FILENAME} to GitHub..."
|
||||||
@@ -250,5 +226,5 @@ jobs:
|
|||||||
"${UPLOAD_URL}?name=${FILENAME}"
|
"${UPLOAD_URL}?name=${FILENAME}"
|
||||||
echo ""
|
echo ""
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "GitHub release complete!"
|
echo "GitHub release complete!"
|
||||||
|
|||||||
2
.gitmodules
vendored
2
.gitmodules
vendored
@@ -1,3 +1,3 @@
|
|||||||
[submodule "cinny"]
|
[submodule "cinny"]
|
||||||
path = cinny
|
path = cinny
|
||||||
url = http://synbox.ruv.wtf:8418/litruv/cinny.git
|
url = ../cinny.git
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
2
cinny
2
cinny
Submodule cinny updated: 61d41900cc...b52926f7d8
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useAtomValue, useStore } from 'jotai';
|
import { useAtomValue, useStore } 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';
|
||||||
@@ -15,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,
|
||||||
@@ -22,7 +23,13 @@ import {
|
|||||||
guessPerfectParent,
|
guessPerfectParent,
|
||||||
} from '../../utils/room';
|
} from '../../utils/room';
|
||||||
import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
|
import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
|
||||||
import { getMxIdLocalPart, mxcUrlToHttp, getCanonicalAliasOrRoomId, encryptFile } 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';
|
||||||
@@ -35,12 +42,17 @@ 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,
|
||||||
@@ -57,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
|
||||||
@@ -226,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,
|
||||||
@@ -238,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 = () => {
|
||||||
@@ -291,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);
|
||||||
},
|
},
|
||||||
@@ -376,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();
|
|
||||||
|
const avatarMxc =
|
||||||
let messageBody: string | undefined;
|
getMemberAvatarMxc(room, sender) ??
|
||||||
if (mEvent.getType() === 'm.reaction') {
|
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ??
|
||||||
// For reactions, show "reacted with {emoji}"
|
room.getMxcAvatarUrl();
|
||||||
const reactionKey = content['m.relates_to']?.key;
|
const roomAvatar = avatarMxc
|
||||||
if (reactionKey) {
|
|
||||||
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) {
|
||||||
@@ -428,6 +588,7 @@ function MessageNotifications() {
|
|||||||
notify,
|
notify,
|
||||||
selectedRoomId,
|
selectedRoomId,
|
||||||
useAuthentication,
|
useAuthentication,
|
||||||
|
mDirects,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -441,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(() => {});
|
||||||
@@ -460,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.129",
|
"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"
|
||||||
|
|||||||
Reference in New Issue
Block a user