Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa86414532 | ||
|
|
83c42d7985 | ||
|
|
898e674160 | ||
|
|
0133505baf | ||
|
|
0ca65c5a13 | ||
|
|
ec2c7634bb | ||
|
|
9a4bb60942 | ||
|
|
6b35927612 | ||
|
|
aada77f30f | ||
| bb768911ef | |||
|
|
a1c6015772 | ||
| d7d7d183c5 | |||
|
|
a5ddd10ff4 | ||
|
|
2921d3a547 | ||
|
|
2be4c66b4a | ||
|
|
f180f4ab7c | ||
|
|
b1167c3eb6 | ||
|
|
16bdf92f77 | ||
|
|
2ebe97c5a4 | ||
|
|
1aef7812a1 | ||
|
|
4f61a31f28 | ||
| 491a5845f0 | |||
|
|
bd25ce8e1d | ||
| a5d44e191b | |||
| 5f4bf191fc | |||
|
|
cfcec87cd9 | ||
| 2c9bcca344 | |||
|
|
9bb7adafd0 | ||
| c9c7bdb48d | |||
|
|
f3d4dcb67e | ||
| a744e36fb1 |
@@ -7,6 +7,7 @@ on:
|
|||||||
- dev
|
- dev
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
increment-version:
|
increment-version:
|
||||||
|
|||||||
95
.gitea/workflows/update-submodule.yml
Normal file
95
.gitea/workflows/update-submodule.yml
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
name: Update cinny submodule
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
cinny_sha:
|
||||||
|
description: "cinny commit SHA to pin (defaults to origin/main)"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: update-cinny-submodule
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
bump:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: false
|
||||||
|
# PAT so the submodule bump push can trigger build.yml
|
||||||
|
# (GITHUB_TOKEN pushes do not start new workflows).
|
||||||
|
token: ${{ secrets.ACTIONS_TOKEN }}
|
||||||
|
ref: master
|
||||||
|
|
||||||
|
- name: Update cinny submodule
|
||||||
|
id: bump
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ORIGIN_URL=$(git remote get-url origin)
|
||||||
|
ORIGIN_URL=${ORIGIN_URL%/}
|
||||||
|
ORIGIN_URL=${ORIGIN_URL%.git}
|
||||||
|
BASE_URL=${ORIGIN_URL%/*}
|
||||||
|
CINNY_URL="${BASE_URL}/cinny.git"
|
||||||
|
echo "Rewriting submodule URL to ${CINNY_URL}"
|
||||||
|
git config submodule.cinny.url "${CINNY_URL}"
|
||||||
|
git submodule sync --recursive
|
||||||
|
git submodule update --init --recursive
|
||||||
|
|
||||||
|
TARGET_SHA="${{ inputs.cinny_sha }}"
|
||||||
|
git -C cinny fetch origin main
|
||||||
|
|
||||||
|
if [ -n "${TARGET_SHA}" ]; then
|
||||||
|
git -C cinny checkout --detach "${TARGET_SHA}"
|
||||||
|
else
|
||||||
|
git -C cinny checkout --detach origin/main
|
||||||
|
fi
|
||||||
|
|
||||||
|
NEW_SHA=$(git -C cinny rev-parse HEAD)
|
||||||
|
OLD_SHA=$(git rev-parse HEAD:cinny)
|
||||||
|
SUBJECT=$(git -C cinny log -1 --pretty=%s)
|
||||||
|
|
||||||
|
echo "old=${OLD_SHA}"
|
||||||
|
echo "new=${NEW_SHA}"
|
||||||
|
echo "subject=${SUBJECT}"
|
||||||
|
|
||||||
|
if [ "${OLD_SHA}" = "${NEW_SHA}" ]; then
|
||||||
|
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "cinny already at ${NEW_SHA}; nothing to do"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
git add cinny
|
||||||
|
git config user.name "GitHub Actions"
|
||||||
|
git config user.email "actions@github.com"
|
||||||
|
git commit -m "Bump cinny: ${SUBJECT}"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "changed=true"
|
||||||
|
echo "sha=${NEW_SHA}"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Push submodule bump
|
||||||
|
if: steps.bump.outputs.changed == 'true'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
git fetch origin master
|
||||||
|
git rebase origin/master || {
|
||||||
|
git rebase --abort || true
|
||||||
|
echo "Failed to rebase submodule bump onto latest master"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if git push origin HEAD:master; then
|
||||||
|
echo "Pushed cinny bump (${{ steps.bump.outputs.sha }})"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Push rejected on attempt ${attempt}, retrying"
|
||||||
|
done
|
||||||
|
echo "Failed to push submodule bump after 3 attempts"
|
||||||
|
exit 1
|
||||||
@@ -9,7 +9,10 @@ android {
|
|||||||
|
|
||||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation project(':capacitor-app')
|
||||||
|
implementation project(':capacitor-browser')
|
||||||
implementation project(':capacitor-local-notifications')
|
implementation project(':capacitor-local-notifications')
|
||||||
|
implementation project(':capacitor-status-bar')
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,14 @@
|
|||||||
<data android:mimeType="*/*" />
|
<data android:mimeType="*/*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- SSO / deep link return: paarrot://android/login/...?loginToken=... -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="paarrot" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import android.graphics.PorterDuffXfermode
|
|||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.Person
|
import androidx.core.app.Person
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
import androidx.core.content.pm.ShortcutInfoCompat
|
import androidx.core.content.pm.ShortcutInfoCompat
|
||||||
import androidx.core.content.pm.ShortcutManagerCompat
|
import androidx.core.content.pm.ShortcutManagerCompat
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
@@ -33,6 +34,8 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
@@ -274,19 +277,17 @@ class MatrixSyncService : Service() {
|
|||||||
|
|
||||||
val body = when {
|
val body = when {
|
||||||
eventType == "m.room.encrypted" -> "Encrypted message"
|
eventType == "m.room.encrypted" -> "Encrypted message"
|
||||||
eventType == "m.sticker" -> {
|
eventType == "m.sticker" -> "🖼️ Sticker"
|
||||||
val raw = content.optString("body")
|
|
||||||
if (raw.isNotBlank()) "🖼️ $raw" else "🖼️ Sticker"
|
|
||||||
}
|
|
||||||
else -> {
|
else -> {
|
||||||
val msgtype = content.optString("msgtype")
|
val msgtype = content.optString("msgtype")
|
||||||
val rawBody = content.optString("body")
|
val rawBody = content.optString("body")
|
||||||
when (msgtype) {
|
when (msgtype) {
|
||||||
"m.image" -> if (rawBody.isNotBlank()) "📷 $rawBody" else "📷 Photo"
|
// Skip Matrix body for images/stickers — it's usually a useless filename.
|
||||||
|
"m.image" -> "📷 Photo"
|
||||||
"m.video" -> if (rawBody.isNotBlank()) "🎥 $rawBody" else "🎥 Video"
|
"m.video" -> if (rawBody.isNotBlank()) "🎥 $rawBody" else "🎥 Video"
|
||||||
"m.audio" -> if (rawBody.isNotBlank()) "🎵 $rawBody" else "🎵 Audio"
|
"m.audio" -> if (rawBody.isNotBlank()) "🎵 $rawBody" else "🎵 Audio"
|
||||||
"m.file" -> if (rawBody.isNotBlank()) "📎 $rawBody" else "📎 File"
|
"m.file" -> if (rawBody.isNotBlank()) "📎 $rawBody" else "📎 File"
|
||||||
"m.sticker" -> if (rawBody.isNotBlank()) "🖼️ $rawBody" else "🖼️ Sticker"
|
"m.sticker" -> "🖼️ Sticker"
|
||||||
else -> rawBody.takeIf { it.isNotBlank() }
|
else -> rawBody.takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -836,10 +837,56 @@ class MatrixSyncService : Service() {
|
|||||||
val sender: String,
|
val sender: String,
|
||||||
val text: String,
|
val text: String,
|
||||||
val timestamp: Long,
|
val timestamp: Long,
|
||||||
|
/** content:// URI for MessagingStyle image data, if any. */
|
||||||
|
val imageUri: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun historyPrefsKey(roomId: String) = KEY_MSG_HISTORY_PREFIX + roomId
|
private fun historyPrefsKey(roomId: String) = KEY_MSG_HISTORY_PREFIX + roomId
|
||||||
|
|
||||||
|
private fun notifImageDir(context: Context): File =
|
||||||
|
File(context.cacheDir, "notif-images").apply { mkdirs() }
|
||||||
|
|
||||||
|
/** Write [bitmap] to cache and return a FileProvider URI SystemUI can read. */
|
||||||
|
private fun persistNotificationImage(context: Context, bitmap: Bitmap): Uri? {
|
||||||
|
return try {
|
||||||
|
val file = File(notifImageDir(context), "img_${System.currentTimeMillis()}.jpg")
|
||||||
|
FileOutputStream(file).use { out ->
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, out)
|
||||||
|
}
|
||||||
|
val uri = FileProvider.getUriForFile(
|
||||||
|
context,
|
||||||
|
"${context.packageName}.fileprovider",
|
||||||
|
file,
|
||||||
|
)
|
||||||
|
// SystemUI reads MessagingStyle image URIs; grant explicitly on OEM builds.
|
||||||
|
runCatching {
|
||||||
|
context.grantUriPermission(
|
||||||
|
"com.android.systemui",
|
||||||
|
uri,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
uri
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to persist notification image: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteNotificationImageUri(context: Context, uriString: String?) {
|
||||||
|
if (uriString.isNullOrBlank()) return
|
||||||
|
try {
|
||||||
|
val uri = Uri.parse(uriString)
|
||||||
|
val path = uri.path ?: return
|
||||||
|
// FileProvider paths look like /my_cache_images/notif-images/...
|
||||||
|
val name = path.substringAfterLast('/')
|
||||||
|
if (name.isBlank()) return
|
||||||
|
File(notifImageDir(context), name).delete()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to delete notification image: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadMessageHistory(context: Context, roomId: String): List<PendingNotifMessage> {
|
private fun loadMessageHistory(context: Context, roomId: String): List<PendingNotifMessage> {
|
||||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString(historyPrefsKey(roomId), null)
|
.getString(historyPrefsKey(roomId), null)
|
||||||
@@ -852,8 +899,9 @@ class MatrixSyncService : Service() {
|
|||||||
val sender = obj.optString("sender")
|
val sender = obj.optString("sender")
|
||||||
val text = obj.optString("text")
|
val text = obj.optString("text")
|
||||||
val ts = obj.optLong("ts", 0L)
|
val ts = obj.optLong("ts", 0L)
|
||||||
|
val imageUri = obj.optString("imageUri").takeIf { it.isNotBlank() }
|
||||||
if (sender.isBlank() || text.isBlank()) continue
|
if (sender.isBlank() || text.isBlank()) continue
|
||||||
add(PendingNotifMessage(sender, text, ts))
|
add(PendingNotifMessage(sender, text, ts, imageUri))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -869,12 +917,14 @@ class MatrixSyncService : Service() {
|
|||||||
) {
|
) {
|
||||||
val arr = JSONArray()
|
val arr = JSONArray()
|
||||||
for (msg in messages) {
|
for (msg in messages) {
|
||||||
arr.put(
|
val obj = JSONObject()
|
||||||
JSONObject()
|
.put("sender", msg.sender)
|
||||||
.put("sender", msg.sender)
|
.put("text", msg.text)
|
||||||
.put("text", msg.text)
|
.put("ts", msg.timestamp)
|
||||||
.put("ts", msg.timestamp)
|
if (!msg.imageUri.isNullOrBlank()) {
|
||||||
)
|
obj.put("imageUri", msg.imageUri)
|
||||||
|
}
|
||||||
|
arr.put(obj)
|
||||||
}
|
}
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.edit()
|
.edit()
|
||||||
@@ -883,6 +933,9 @@ class MatrixSyncService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun clearMessageHistory(context: Context, roomId: String) {
|
private fun clearMessageHistory(context: Context, roomId: String) {
|
||||||
|
for (msg in loadMessageHistory(context, roomId)) {
|
||||||
|
deleteNotificationImageUri(context, msg.imageUri)
|
||||||
|
}
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.edit()
|
.edit()
|
||||||
.remove(historyPrefsKey(roomId))
|
.remove(historyPrefsKey(roomId))
|
||||||
@@ -894,12 +947,19 @@ class MatrixSyncService : Service() {
|
|||||||
roomId: String,
|
roomId: String,
|
||||||
sender: String,
|
sender: String,
|
||||||
text: String,
|
text: String,
|
||||||
|
imageUri: String? = null,
|
||||||
): List<PendingNotifMessage> {
|
): List<PendingNotifMessage> {
|
||||||
val next = (loadMessageHistory(context, roomId) + PendingNotifMessage(
|
val previous = loadMessageHistory(context, roomId)
|
||||||
|
val next = (previous + PendingNotifMessage(
|
||||||
sender = sender,
|
sender = sender,
|
||||||
text = text,
|
text = text,
|
||||||
timestamp = System.currentTimeMillis(),
|
timestamp = System.currentTimeMillis(),
|
||||||
|
imageUri = imageUri,
|
||||||
)).takeLast(MAX_MESSAGING_HISTORY)
|
)).takeLast(MAX_MESSAGING_HISTORY)
|
||||||
|
// Drop image files for messages that fell out of the sliding window.
|
||||||
|
for (dropped in previous - next.toSet()) {
|
||||||
|
deleteNotificationImageUri(context, dropped.imageUri)
|
||||||
|
}
|
||||||
saveMessageHistory(context, roomId, next)
|
saveMessageHistory(context, roomId, next)
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
@@ -967,7 +1027,14 @@ class MatrixSyncService : Service() {
|
|||||||
}
|
}
|
||||||
val senderPerson = personBuilder.build()
|
val senderPerson = personBuilder.build()
|
||||||
|
|
||||||
val history = appendMessageHistory(context, roomId, resolvedSender, resolvedMessage)
|
val imageUri = inlineImage?.let { persistNotificationImage(context, it) }?.toString()
|
||||||
|
val history = appendMessageHistory(
|
||||||
|
context,
|
||||||
|
roomId,
|
||||||
|
resolvedSender,
|
||||||
|
resolvedMessage,
|
||||||
|
imageUri,
|
||||||
|
)
|
||||||
|
|
||||||
val channelId = channelIdForKindStatic(kind)
|
val channelId = channelIdForKindStatic(kind)
|
||||||
val collapsedTitle = if (isDm || resolvedConversation.isNullOrBlank()) {
|
val collapsedTitle = if (isDm || resolvedConversation.isNullOrBlank()) {
|
||||||
@@ -1008,13 +1075,17 @@ class MatrixSyncService : Service() {
|
|||||||
.setImportant(true)
|
.setImportant(true)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
messagingStyle.addMessage(
|
val line = NotificationCompat.MessagingStyle.Message(
|
||||||
NotificationCompat.MessagingStyle.Message(
|
msg.text,
|
||||||
msg.text,
|
msg.timestamp,
|
||||||
msg.timestamp,
|
person,
|
||||||
person,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
// Attach image on the message itself so multi-message history keeps thumbnails
|
||||||
|
// (BigPictureStyle replaces MessagingStyle and culls prior lines).
|
||||||
|
if (!msg.imageUri.isNullOrBlank()) {
|
||||||
|
line.setData("image/jpeg", Uri.parse(msg.imageUri))
|
||||||
|
}
|
||||||
|
messagingStyle.addMessage(line)
|
||||||
}
|
}
|
||||||
|
|
||||||
val builder = NotificationCompat.Builder(context, channelId)
|
val builder = NotificationCompat.Builder(context, channelId)
|
||||||
@@ -1044,17 +1115,6 @@ class MatrixSyncService : Service() {
|
|||||||
// Collapsed shade avatar (large icon). Small icon must stay the monochrome app mark.
|
// Collapsed shade avatar (large icon). Small icon must stay the monochrome app mark.
|
||||||
if (avatar != null) builder.setLargeIcon(avatar)
|
if (avatar != null) builder.setLargeIcon(avatar)
|
||||||
|
|
||||||
// Image attachments: keep MessagingStyle history; BigPicture would wipe prior lines.
|
|
||||||
if (inlineImage != null && history.size == 1) {
|
|
||||||
builder.setStyle(
|
|
||||||
NotificationCompat.BigPictureStyle()
|
|
||||||
.bigPicture(inlineImage)
|
|
||||||
.bigLargeIcon(avatar)
|
|
||||||
.setBigContentTitle(collapsedTitle)
|
|
||||||
.setSummaryText(collapsedText)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
nm.notify(roomNotifId, builder.build())
|
nm.notify(roomNotifId, builder.build())
|
||||||
|
|
||||||
val summaryId = notificationIdForGroupSummary(groupId)
|
val summaryId = notificationIdForGroupSummary(groupId)
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ package com.paarrot.app
|
|||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.app.AlertDialog
|
import android.app.AlertDialog
|
||||||
|
import android.app.AppOpsManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.Process
|
||||||
|
import android.os.SystemClock
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.getcapacitor.JSArray
|
import com.getcapacitor.JSArray
|
||||||
import com.getcapacitor.JSObject
|
import com.getcapacitor.JSObject
|
||||||
@@ -20,15 +26,26 @@ object UnifiedPushManager {
|
|||||||
private const val PREFS = "unifiedpush_prefs"
|
private const val PREFS = "unifiedpush_prefs"
|
||||||
private const val KEY_ENDPOINT = "endpoint"
|
private const val KEY_ENDPOINT = "endpoint"
|
||||||
private const val KEY_INSTANCE = "instance"
|
private const val KEY_INSTANCE = "instance"
|
||||||
|
private const val KEY_LAST_FAILURE = "last_failure"
|
||||||
private const val EVENT_NEW_ENDPOINT = "unifiedPushNewEndpoint"
|
private const val EVENT_NEW_ENDPOINT = "unifiedPushNewEndpoint"
|
||||||
private const val EVENT_UNREGISTERED = "unifiedPushUnregistered"
|
private const val EVENT_UNREGISTERED = "unifiedPushUnregistered"
|
||||||
private const val EVENT_REGISTRATION_FAILED = "unifiedPushRegistrationFailed"
|
private const val EVENT_REGISTRATION_FAILED = "unifiedPushRegistrationFailed"
|
||||||
private const val DEFAULT_MESSAGE = "Paarrot notifications"
|
private const val DEFAULT_MESSAGE = "Paarrot notifications"
|
||||||
|
private const val REGISTER_DELAY_MS = 750L
|
||||||
|
private const val IGNORE_UNREGISTER_MS = 2_500L
|
||||||
const val DEFAULT_MATRIX_GATEWAY = "https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify"
|
const val DEFAULT_MATRIX_GATEWAY = "https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify"
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var plugin: SyncServicePlugin? = null
|
private var plugin: SyncServicePlugin? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var setupGeneration = 0
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var ignoreUnregisteredUntilElapsed = 0L
|
||||||
|
|
||||||
fun setPlugin(plugin: SyncServicePlugin) {
|
fun setPlugin(plugin: SyncServicePlugin) {
|
||||||
this.plugin = plugin
|
this.plugin = plugin
|
||||||
}
|
}
|
||||||
@@ -61,19 +78,24 @@ object UnifiedPushManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears any saved distributor and shows an in-app distributor picker.
|
* Shows an in-app distributor picker, then registers after a short delay.
|
||||||
* UnifiedPush's OS deeplink picker is silent when only one distributor
|
*
|
||||||
* (e.g. ntfy) is installed, which looked like a broken Reset button.
|
* Important: we intentionally avoid calling [UnifiedPush.removeDistributor] before the
|
||||||
|
* user picks. That sent an UNREGISTER that could arrive *after* the new REGISTER and
|
||||||
|
* wipe the fresh endpoint (looked like "found ntfy but never connects").
|
||||||
*/
|
*/
|
||||||
fun requestDistributorSetup(context: Context, activity: Activity, onDone: (Boolean) -> Unit) {
|
fun requestDistributorSetup(context: Context, activity: Activity, onDone: (Boolean) -> Unit) {
|
||||||
runCatching { UnifiedPush.removeDistributor(context) }
|
val gen = ++setupGeneration
|
||||||
.onFailure { Log.w(TAG, "removeDistributor failed: ${it.message}") }
|
ignoreUnregisteredUntilElapsed = SystemClock.elapsedRealtime() + IGNORE_UNREGISTER_MS
|
||||||
clearEndpoint(context)
|
clearEndpoint(context)
|
||||||
|
clearLastFailure(context)
|
||||||
|
|
||||||
val distributors = runCatching { UnifiedPush.getDistributors(context) }
|
val distributors = runCatching { UnifiedPush.getDistributors(context) }
|
||||||
.getOrDefault(emptyList())
|
.getOrDefault(emptyList())
|
||||||
|
Log.i(TAG, "Distributor setup: found=${distributors.joinToString()}")
|
||||||
if (distributors.isEmpty()) {
|
if (distributors.isEmpty()) {
|
||||||
Log.w(TAG, "No UnifiedPush distributors installed")
|
Log.w(TAG, "No UnifiedPush distributors installed")
|
||||||
|
persistLastFailure(context, "NO_DISTRIBUTOR")
|
||||||
dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
|
dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
|
||||||
onDone(false)
|
onDone(false)
|
||||||
return
|
return
|
||||||
@@ -98,10 +120,7 @@ object UnifiedPushManager {
|
|||||||
finish(false)
|
finish(false)
|
||||||
return@setItems
|
return@setItems
|
||||||
}
|
}
|
||||||
runCatching { UnifiedPush.saveDistributor(context, chosen) }
|
activateDistributor(context, chosen, gen, finish)
|
||||||
.onFailure { Log.w(TAG, "saveDistributor failed: ${it.message}") }
|
|
||||||
requestRegistration(context)
|
|
||||||
finish(true)
|
|
||||||
}
|
}
|
||||||
.setNegativeButton(android.R.string.cancel) { _, _ -> finish(false) }
|
.setNegativeButton(android.R.string.cancel) { _, _ -> finish(false) }
|
||||||
.setOnCancelListener { finish(false) }
|
.setOnCancelListener { finish(false) }
|
||||||
@@ -109,6 +128,76 @@ object UnifiedPushManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves [chosen] as the distributor and registers after a delay so any prior
|
||||||
|
* UNREGISTER from a distributor switch can settle first.
|
||||||
|
*/
|
||||||
|
private fun activateDistributor(
|
||||||
|
context: Context,
|
||||||
|
chosen: String,
|
||||||
|
gen: Int,
|
||||||
|
finish: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
ignoreUnregisteredUntilElapsed = SystemClock.elapsedRealtime() + IGNORE_UNREGISTER_MS
|
||||||
|
val previous = runCatching { UnifiedPush.getSavedDistributor(context) }.getOrNull().orEmpty()
|
||||||
|
Log.i(TAG, "Activating distributor=$chosen previous=$previous")
|
||||||
|
|
||||||
|
if (previous.isNotBlank() && previous != chosen) {
|
||||||
|
runCatching { UnifiedPush.removeDistributor(context) }
|
||||||
|
.onFailure { Log.w(TAG, "removeDistributor failed: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
mainHandler.postDelayed({
|
||||||
|
if (gen != setupGeneration) {
|
||||||
|
Log.w(TAG, "Skipping stale distributor activation gen=$gen current=$setupGeneration")
|
||||||
|
finish(false)
|
||||||
|
return@postDelayed
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching { UnifiedPush.saveDistributor(context, chosen) }
|
||||||
|
.onFailure {
|
||||||
|
Log.w(TAG, "saveDistributor failed: ${it.message}")
|
||||||
|
persistLastFailure(context, "SAVE_FAILED")
|
||||||
|
finish(false)
|
||||||
|
return@postDelayed
|
||||||
|
}
|
||||||
|
|
||||||
|
val saved = runCatching { UnifiedPush.getSavedDistributor(context) }.getOrNull()
|
||||||
|
if (saved != chosen) {
|
||||||
|
Log.e(TAG, "Distributor not saved after saveDistributor (got=$saved)")
|
||||||
|
persistLastFailure(context, "SAVE_NOT_PERSISTED")
|
||||||
|
finish(false)
|
||||||
|
return@postDelayed
|
||||||
|
}
|
||||||
|
|
||||||
|
requestRegistration(context)
|
||||||
|
// Report true once register was requested; JS polls for the endpoint.
|
||||||
|
finish(true)
|
||||||
|
|
||||||
|
// One retry if the distributor is slow to ack.
|
||||||
|
mainHandler.postDelayed({
|
||||||
|
if (gen != setupGeneration) return@postDelayed
|
||||||
|
if (!getEndpoint(context).isNullOrBlank()) return@postDelayed
|
||||||
|
Log.w(TAG, "No endpoint yet after ${REGISTER_DELAY_MS}ms — retrying register")
|
||||||
|
requestRegistration(context)
|
||||||
|
|
||||||
|
// OEM "App Boot" / AUTO_START can silently drop NEW_ENDPOINT (seen on TCL).
|
||||||
|
mainHandler.postDelayed({
|
||||||
|
if (gen != setupGeneration) return@postDelayed
|
||||||
|
if (!getEndpoint(context).isNullOrBlank()) return@postDelayed
|
||||||
|
val reason = if (isAutoStartRestricted(context)) {
|
||||||
|
"AUTO_START_BLOCKED"
|
||||||
|
} else {
|
||||||
|
"NO_ENDPOINT"
|
||||||
|
}
|
||||||
|
Log.w(TAG, "Still no endpoint after register retries — $reason")
|
||||||
|
persistLastFailure(context, reason)
|
||||||
|
dispatchRegistrationFailed(reason, INSTANCE_DEFAULT)
|
||||||
|
}, 4_000L)
|
||||||
|
}, REGISTER_DELAY_MS)
|
||||||
|
}, if (previous.isNotBlank() && previous != chosen) REGISTER_DELAY_MS else 150L)
|
||||||
|
}
|
||||||
|
|
||||||
/** Human-readable label for a distributor package (falls back to package name). */
|
/** Human-readable label for a distributor package (falls back to package name). */
|
||||||
private fun distributorLabel(context: Context, packageName: String): String {
|
private fun distributorLabel(context: Context, packageName: String): String {
|
||||||
return try {
|
return try {
|
||||||
@@ -173,6 +262,8 @@ object UnifiedPushManager {
|
|||||||
status.put("instance", getInstance(context) ?: INSTANCE_DEFAULT)
|
status.put("instance", getInstance(context) ?: INSTANCE_DEFAULT)
|
||||||
status.put("registered", !getEndpoint(context).isNullOrBlank())
|
status.put("registered", !getEndpoint(context).isNullOrBlank())
|
||||||
status.put("distributor", runCatching { UnifiedPush.getSavedDistributor(context) }.getOrDefault(""))
|
status.put("distributor", runCatching { UnifiedPush.getSavedDistributor(context) }.getOrDefault(""))
|
||||||
|
status.put("lastFailure", getLastFailure(context) ?: "")
|
||||||
|
status.put("autoStartBlocked", isAutoStartRestricted(context))
|
||||||
val distributors = JSArray()
|
val distributors = JSArray()
|
||||||
runCatching { UnifiedPush.getDistributors(context) }
|
runCatching { UnifiedPush.getDistributors(context) }
|
||||||
.getOrDefault(emptyList())
|
.getOrDefault(emptyList())
|
||||||
@@ -181,7 +272,29 @@ object UnifiedPushManager {
|
|||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TCL / some OEMs expose an AUTO_START app-op. When it is not MODE_ALLOWED,
|
||||||
|
* [AppBootManager] drops distributor broadcasts such as NEW_ENDPOINT, so
|
||||||
|
* UnifiedPush never finishes registering even though ntfy is selected.
|
||||||
|
*/
|
||||||
|
private fun isAutoStartRestricted(context: Context): Boolean {
|
||||||
|
return try {
|
||||||
|
val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
|
||||||
|
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
appOps.unsafeCheckOpNoThrow("android:auto_start", Process.myUid(), context.packageName)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
appOps.checkOpNoThrow("android:auto_start", Process.myUid(), context.packageName)
|
||||||
|
}
|
||||||
|
mode != AppOpsManager.MODE_ALLOWED && mode != AppOpsManager.MODE_DEFAULT
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
|
fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
|
||||||
|
Log.i(TAG, "onNewEndpoint instance=$instance endpoint=$endpoint")
|
||||||
|
clearLastFailure(context)
|
||||||
val previousEndpoint = getEndpoint(context)
|
val previousEndpoint = getEndpoint(context)
|
||||||
persistEndpoint(context, endpoint, instance)
|
persistEndpoint(context, endpoint, instance)
|
||||||
val payload = JSObject().apply {
|
val payload = JSObject().apply {
|
||||||
@@ -192,11 +305,18 @@ object UnifiedPushManager {
|
|||||||
dispatch(EVENT_NEW_ENDPOINT, payload)
|
dispatch(EVENT_NEW_ENDPOINT, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onRegistrationFailed(reason: String, instance: String) {
|
fun onRegistrationFailed(context: Context, reason: String, instance: String) {
|
||||||
|
Log.w(TAG, "onRegistrationFailed reason=$reason instance=$instance")
|
||||||
|
persistLastFailure(context, reason)
|
||||||
dispatchRegistrationFailed(reason, instance)
|
dispatchRegistrationFailed(reason, instance)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onUnregistered(context: Context, instance: String) {
|
fun onUnregistered(context: Context, instance: String) {
|
||||||
|
if (SystemClock.elapsedRealtime() < ignoreUnregisteredUntilElapsed) {
|
||||||
|
Log.i(TAG, "Ignoring unregistered during setup window instance=$instance")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log.i(TAG, "onUnregistered instance=$instance")
|
||||||
val previousEndpoint = getEndpoint(context)
|
val previousEndpoint = getEndpoint(context)
|
||||||
clearEndpoint(context)
|
clearEndpoint(context)
|
||||||
dispatchUnregistered(previousEndpoint, instance)
|
dispatchUnregistered(previousEndpoint, instance)
|
||||||
@@ -204,9 +324,11 @@ object UnifiedPushManager {
|
|||||||
|
|
||||||
private fun requestRegistration(context: Context) {
|
private fun requestRegistration(context: Context) {
|
||||||
runCatching {
|
runCatching {
|
||||||
|
Log.i(TAG, "Calling UnifiedPush.register instance=$INSTANCE_DEFAULT")
|
||||||
UnifiedPush.register(context, INSTANCE_DEFAULT, DEFAULT_MESSAGE, null)
|
UnifiedPush.register(context, INSTANCE_DEFAULT, DEFAULT_MESSAGE, null)
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
Log.w(TAG, "UnifiedPush register failed: ${it.message}")
|
Log.w(TAG, "UnifiedPush register failed: ${it.message}")
|
||||||
|
persistLastFailure(context, FailedReason.INTERNAL_ERROR.name)
|
||||||
dispatchRegistrationFailed(FailedReason.INTERNAL_ERROR.name, INSTANCE_DEFAULT)
|
dispatchRegistrationFailed(FailedReason.INTERNAL_ERROR.name, INSTANCE_DEFAULT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,6 +367,21 @@ object UnifiedPushManager {
|
|||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun persistLastFailure(context: Context, reason: String) {
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||||
|
.putString(KEY_LAST_FAILURE, reason)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearLastFailure(context: Context) {
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||||
|
.remove(KEY_LAST_FAILURE)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getLastFailure(context: Context): String? =
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_LAST_FAILURE, null)
|
||||||
|
|
||||||
private fun getEndpoint(context: Context): String? =
|
private fun getEndpoint(context: Context): String? =
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_ENDPOINT, null)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_ENDPOINT, null)
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class UnifiedPushService : PushService() {
|
|||||||
|
|
||||||
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
|
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
|
||||||
Log.w(TAG, "UnifiedPush registration failed: $reason")
|
Log.w(TAG, "UnifiedPush registration failed: $reason")
|
||||||
UnifiedPushManager.onRegistrationFailed(reason.name, instance)
|
UnifiedPushManager.onRegistrationFailed(applicationContext, reason.name, instance)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onUnregistered(instance: String) {
|
override fun onUnregistered(instance: String) {
|
||||||
|
|||||||
@@ -2,5 +2,14 @@
|
|||||||
include ':capacitor-android'
|
include ':capacitor-android'
|
||||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||||
|
|
||||||
|
include ':capacitor-app'
|
||||||
|
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||||
|
|
||||||
|
include ':capacitor-browser'
|
||||||
|
project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android')
|
||||||
|
|
||||||
include ':capacitor-local-notifications'
|
include ':capacitor-local-notifications'
|
||||||
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
|
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
|
||||||
|
|
||||||
|
include ':capacitor-status-bar'
|
||||||
|
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
|
||||||
|
|||||||
2
cinny
2
cinny
Submodule cinny updated: e460dbd34e...b6f3f5c0aa
@@ -1,5 +1,7 @@
|
|||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@capacitor/app": "8.0.1",
|
||||||
|
"@capacitor/browser": "8.0.1",
|
||||||
"@capacitor/status-bar": "8.0.1"
|
"@capacitor/status-bar": "8.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,251 +0,0 @@
|
|||||||
/* eslint-disable no-param-reassign */
|
|
||||||
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
|
||||||
import React, { FormEventHandler, MouseEventHandler, useEffect, useRef, useState } from 'react';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { Box, Button, Chip, Header, IconButton, Input, Menu, PopOut, RectCords, Scroll, Spinner, Text, as, config } from 'folds';
|
|
||||||
import { Icon, Icons } from '../icons';
|
|
||||||
import FocusTrap from 'focus-trap-react';
|
|
||||||
import { saveMediaBlob } from '../../utils/saveMedia';
|
|
||||||
import * as css from './PdfViewer.css';
|
|
||||||
import { AsyncStatus } from '../../hooks/useAsyncCallback';
|
|
||||||
import { useZoom } from '../../hooks/useZoom';
|
|
||||||
import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist';
|
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
|
||||||
|
|
||||||
export type PdfViewerProps = {
|
|
||||||
name: string;
|
|
||||||
src: string;
|
|
||||||
requestClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PdfViewer = as<'div', PdfViewerProps>(
|
|
||||||
({ className, name, src, requestClose, ...props }, ref) => {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
|
||||||
|
|
||||||
const [pdfJSState, loadPdfJS] = usePdfJSLoader();
|
|
||||||
const [docState, loadPdfDocument] = usePdfDocumentLoader(
|
|
||||||
pdfJSState.status === AsyncStatus.Success ? pdfJSState.data : undefined,
|
|
||||||
src
|
|
||||||
);
|
|
||||||
const isLoading =
|
|
||||||
pdfJSState.status === AsyncStatus.Loading || docState.status === AsyncStatus.Loading;
|
|
||||||
const isError =
|
|
||||||
pdfJSState.status === AsyncStatus.Error || docState.status === AsyncStatus.Error;
|
|
||||||
const [pageNo, setPageNo] = useState(1);
|
|
||||||
const [jumpAnchor, setJumpAnchor] = useState<RectCords>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadPdfJS();
|
|
||||||
}, [loadPdfJS]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (pdfJSState.status === AsyncStatus.Success) {
|
|
||||||
loadPdfDocument();
|
|
||||||
}
|
|
||||||
}, [pdfJSState, loadPdfDocument]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (docState.status === AsyncStatus.Success) {
|
|
||||||
const doc = docState.data;
|
|
||||||
if (pageNo < 0 || pageNo > doc.numPages) return;
|
|
||||||
createPage(doc, pageNo, { scale: zoom }).then((canvas) => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
container.textContent = '';
|
|
||||||
container.append(canvas);
|
|
||||||
scrollRef.current?.scrollTo({
|
|
||||||
top: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [docState, pageNo, zoom]);
|
|
||||||
|
|
||||||
const handleDownload = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(src);
|
|
||||||
if (!res.ok) throw new Error('Failed to fetch PDF');
|
|
||||||
await saveMediaBlob(await res.blob(), name);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[PdfViewer] Failed to download:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
if (docState.status !== AsyncStatus.Success) return;
|
|
||||||
const jumpInput = evt.currentTarget.jumpInput as HTMLInputElement;
|
|
||||||
if (!jumpInput) return;
|
|
||||||
const jumpTo = parseInt(jumpInput.value, 10);
|
|
||||||
setPageNo(Math.max(1, Math.min(docState.data.numPages, jumpTo)));
|
|
||||||
setJumpAnchor(undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrevPage = () => {
|
|
||||||
setPageNo((n) => Math.max(n - 1, 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNextPage = () => {
|
|
||||||
if (docState.status !== AsyncStatus.Success) return;
|
|
||||||
setPageNo((n) => Math.min(n + 1, docState.data.numPages));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenJump: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
|
||||||
setJumpAnchor(evt.currentTarget.getBoundingClientRect());
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}>
|
|
||||||
<Header className={css.PdfViewerHeader} size="400">
|
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
|
||||||
<IconButton size="300" radii="300" onClick={requestClose}>
|
|
||||||
<Icon size="50" src={Icons.ArrowLeft} />
|
|
||||||
</IconButton>
|
|
||||||
<Text size="T300" truncate>
|
|
||||||
{name}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Box shrink="No" alignItems="Center" gap="200">
|
|
||||||
<IconButton
|
|
||||||
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
|
|
||||||
outlined={zoom < 1}
|
|
||||||
size="300"
|
|
||||||
radii="Pill"
|
|
||||||
onClick={zoomOut}
|
|
||||||
aria-label="Zoom Out"
|
|
||||||
>
|
|
||||||
<Icon size="50" src={Icons.Minus} />
|
|
||||||
</IconButton>
|
|
||||||
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
|
|
||||||
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
|
||||||
</Chip>
|
|
||||||
<IconButton
|
|
||||||
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
|
|
||||||
outlined={zoom > 1}
|
|
||||||
size="300"
|
|
||||||
radii="Pill"
|
|
||||||
onClick={zoomIn}
|
|
||||||
aria-label="Zoom In"
|
|
||||||
>
|
|
||||||
<Icon size="50" src={Icons.Plus} />
|
|
||||||
</IconButton>
|
|
||||||
<Chip
|
|
||||||
variant="Primary"
|
|
||||||
onClick={handleDownload}
|
|
||||||
radii="300"
|
|
||||||
before={<Icon size="50" src={Icons.Download} />}
|
|
||||||
>
|
|
||||||
<Text size="B300">Download</Text>
|
|
||||||
</Chip>
|
|
||||||
</Box>
|
|
||||||
</Header>
|
|
||||||
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
|
|
||||||
{isLoading && <Spinner variant="Secondary" size="600" />}
|
|
||||||
{isError && (
|
|
||||||
<>
|
|
||||||
<Text>Failed to load PDF</Text>
|
|
||||||
<Button
|
|
||||||
variant="Critical"
|
|
||||||
fill="Soft"
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
before={<Icon src={Icons.Warning} size="50" />}
|
|
||||||
onClick={loadPdfJS}
|
|
||||||
>
|
|
||||||
<Text size="B300">Retry</Text>
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{docState.status === AsyncStatus.Success && (
|
|
||||||
<Scroll
|
|
||||||
ref={scrollRef}
|
|
||||||
size="300"
|
|
||||||
direction="Both"
|
|
||||||
variant="Surface"
|
|
||||||
visibility="Hover"
|
|
||||||
>
|
|
||||||
<Box>
|
|
||||||
<div className={css.PdfViewerContent} ref={containerRef} />
|
|
||||||
</Box>
|
|
||||||
</Scroll>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
{docState.status === AsyncStatus.Success && (
|
|
||||||
<Header as="footer" className={css.PdfViewerFooter} size="400">
|
|
||||||
<Chip
|
|
||||||
variant="Secondary"
|
|
||||||
radii="300"
|
|
||||||
before={<Icon size="50" src={Icons.ChevronLeft} />}
|
|
||||||
onClick={handlePrevPage}
|
|
||||||
aria-disabled={pageNo <= 1}
|
|
||||||
>
|
|
||||||
<Text size="B300">Previous</Text>
|
|
||||||
</Chip>
|
|
||||||
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
|
|
||||||
<PopOut
|
|
||||||
anchor={jumpAnchor}
|
|
||||||
align="Center"
|
|
||||||
position="Top"
|
|
||||||
content={
|
|
||||||
<FocusTrap
|
|
||||||
focusTrapOptions={{
|
|
||||||
initialFocus: false,
|
|
||||||
onDeactivate: () => setJumpAnchor(undefined),
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Menu variant="Surface">
|
|
||||||
<Box
|
|
||||||
as="form"
|
|
||||||
onSubmit={handleJumpSubmit}
|
|
||||||
style={{ padding: config.space.S200 }}
|
|
||||||
direction="Column"
|
|
||||||
gap="200"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
name="jumpInput"
|
|
||||||
size="300"
|
|
||||||
variant="Background"
|
|
||||||
defaultValue={pageNo}
|
|
||||||
min={1}
|
|
||||||
max={docState.data.numPages}
|
|
||||||
step={1}
|
|
||||||
outlined
|
|
||||||
type="number"
|
|
||||||
radii="300"
|
|
||||||
aria-label="Page Number"
|
|
||||||
/>
|
|
||||||
<Button type="submit" size="300" variant="Primary" radii="300">
|
|
||||||
<Text size="B300">Jump To Page</Text>
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Menu>
|
|
||||||
</FocusTrap>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Chip
|
|
||||||
onClick={handleOpenJump}
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
radii="300"
|
|
||||||
aria-pressed={jumpAnchor !== undefined}
|
|
||||||
>
|
|
||||||
<Text size="B300">{`${pageNo}/${docState.data.numPages}`}</Text>
|
|
||||||
</Chip>
|
|
||||||
</PopOut>
|
|
||||||
</Box>
|
|
||||||
<Chip
|
|
||||||
variant="Primary"
|
|
||||||
radii="300"
|
|
||||||
after={<Icon size="50" src={Icons.ChevronRight} />}
|
|
||||||
onClick={handleNextPage}
|
|
||||||
aria-disabled={pageNo >= docState.data.numPages}
|
|
||||||
>
|
|
||||||
<Text size="B300">Next</Text>
|
|
||||||
</Chip>
|
|
||||||
</Header>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
|
||||||
import React from 'react';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { Box, Chip, Header, IconButton, Text, as } from 'folds';
|
|
||||||
import { Icon, Icons } from '../icons';
|
|
||||||
import * as css from './ImageViewer.css';
|
|
||||||
import { useZoom } from '../../hooks/useZoom';
|
|
||||||
import { usePan } from '../../hooks/usePan';
|
|
||||||
import { downloadAndSaveMedia } from '../../utils/saveMedia';
|
|
||||||
import { getCurrentAccessToken } from '../../utils/auth';
|
|
||||||
|
|
||||||
export type ImageViewerProps = {
|
|
||||||
alt: string;
|
|
||||||
src: string;
|
|
||||||
requestClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ImageViewer = as<'div', ImageViewerProps>(
|
|
||||||
({ className, alt, src, requestClose, ...props }, ref) => {
|
|
||||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
|
||||||
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
|
||||||
|
|
||||||
const handleDownload = async () => {
|
|
||||||
try {
|
|
||||||
await downloadAndSaveMedia(src, alt, getCurrentAccessToken());
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[ImageViewer] Failed to download media:', error);
|
|
||||||
try {
|
|
||||||
window.open(src, '_blank');
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
className={classNames(css.ImageViewer, className)}
|
|
||||||
direction="Column"
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
>
|
|
||||||
<Header className={css.ImageViewerHeader} size="400">
|
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
|
||||||
<IconButton size="300" radii="300" onClick={requestClose}>
|
|
||||||
<Icon size="50" src={Icons.ArrowLeft} />
|
|
||||||
</IconButton>
|
|
||||||
<Text size="T300" truncate>
|
|
||||||
{alt}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Box shrink="No" alignItems="Center" gap="200">
|
|
||||||
<IconButton
|
|
||||||
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
|
|
||||||
outlined={zoom < 1}
|
|
||||||
size="300"
|
|
||||||
radii="Pill"
|
|
||||||
onClick={zoomOut}
|
|
||||||
aria-label="Zoom Out"
|
|
||||||
>
|
|
||||||
<Icon size="50" src={Icons.Minus} />
|
|
||||||
</IconButton>
|
|
||||||
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
|
|
||||||
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
|
||||||
</Chip>
|
|
||||||
<IconButton
|
|
||||||
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
|
|
||||||
outlined={zoom > 1}
|
|
||||||
size="300"
|
|
||||||
radii="Pill"
|
|
||||||
onClick={zoomIn}
|
|
||||||
aria-label="Zoom In"
|
|
||||||
>
|
|
||||||
<Icon size="50" src={Icons.Plus} />
|
|
||||||
</IconButton>
|
|
||||||
<Chip
|
|
||||||
variant="Primary"
|
|
||||||
onClick={handleDownload}
|
|
||||||
radii="300"
|
|
||||||
before={<Icon size="50" src={Icons.Download} />}
|
|
||||||
>
|
|
||||||
<Text size="B300">Download</Text>
|
|
||||||
</Chip>
|
|
||||||
</Box>
|
|
||||||
</Header>
|
|
||||||
<Box
|
|
||||||
grow="Yes"
|
|
||||||
className={css.ImageViewerContent}
|
|
||||||
justifyContent="Center"
|
|
||||||
alignItems="Center"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
className={css.ImageViewerImg}
|
|
||||||
style={{
|
|
||||||
cursor,
|
|
||||||
transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`,
|
|
||||||
}}
|
|
||||||
src={src}
|
|
||||||
alt={alt}
|
|
||||||
draggable={false}
|
|
||||||
onMouseDown={onMouseDown}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { Badge, Box, IconButton, Spinner, Text, as, toRem } from 'folds';
|
|
||||||
import { Icon, Icons } from '../icons';
|
|
||||||
import React, { ReactNode, useCallback } from 'react';
|
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
|
||||||
import { mimeTypeToExt } from '../../utils/mimeTypes';
|
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
|
||||||
import {
|
|
||||||
decryptFile,
|
|
||||||
downloadEncryptedMedia,
|
|
||||||
downloadMedia,
|
|
||||||
mxcUrlToHttp,
|
|
||||||
} from '../../utils/matrix';
|
|
||||||
import { getCurrentAccessToken } from '../../utils/auth';
|
|
||||||
import { saveMediaBlob } from '../../utils/saveMedia';
|
|
||||||
|
|
||||||
const badgeStyles = { maxWidth: toRem(100) };
|
|
||||||
|
|
||||||
type FileDownloadButtonProps = {
|
|
||||||
filename: string;
|
|
||||||
url: string;
|
|
||||||
mimeType: string;
|
|
||||||
encInfo?: EncryptedAttachmentInfo;
|
|
||||||
};
|
|
||||||
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const useAuthentication = useMediaAuthentication();
|
|
||||||
|
|
||||||
const [downloadState, download] = useAsyncCallback(
|
|
||||||
useCallback(async () => {
|
|
||||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
const fileContent = encInfo
|
|
||||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
|
||||||
: await downloadMedia(mediaUrl, accessToken);
|
|
||||||
|
|
||||||
await saveMediaBlob(fileContent, filename);
|
|
||||||
return true;
|
|
||||||
}, [mx, url, useAuthentication, mimeType, encInfo, filename])
|
|
||||||
);
|
|
||||||
|
|
||||||
const downloading = downloadState.status === AsyncStatus.Loading;
|
|
||||||
const hasError = downloadState.status === AsyncStatus.Error;
|
|
||||||
return (
|
|
||||||
<IconButton
|
|
||||||
disabled={downloading}
|
|
||||||
onClick={download}
|
|
||||||
variant={hasError ? 'Critical' : 'SurfaceVariant'}
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
>
|
|
||||||
{downloading ? (
|
|
||||||
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
|
|
||||||
) : (
|
|
||||||
<Icon size="100" src={Icons.Download} />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FileHeaderProps = {
|
|
||||||
body: string;
|
|
||||||
mimeType: string;
|
|
||||||
after?: ReactNode;
|
|
||||||
};
|
|
||||||
export const FileHeader = as<'div', FileHeaderProps>(({ body, mimeType, after, ...props }, ref) => (
|
|
||||||
<Box alignItems="Center" gap="200" grow="Yes" {...props} ref={ref}>
|
|
||||||
<Box shrink="No">
|
|
||||||
<Badge style={badgeStyles} variant="Secondary" radii="Pill">
|
|
||||||
<Text size="O400" truncate>
|
|
||||||
{mimeTypeToExt(mimeType)}
|
|
||||||
</Text>
|
|
||||||
</Badge>
|
|
||||||
</Box>
|
|
||||||
<Box grow="Yes">
|
|
||||||
<Text size="T300" truncate>
|
|
||||||
{body}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
{after}
|
|
||||||
</Box>
|
|
||||||
));
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
import React, { ReactNode, useCallback, useState } from 'react';
|
|
||||||
import { Box, Button, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
|
|
||||||
import { Icon, Icons } from '../../icons';
|
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
|
||||||
import { saveMediaBlob } from '../../../utils/saveMedia';
|
|
||||||
import FocusTrap from 'focus-trap-react';
|
|
||||||
import { IFileInfo } from '../../../../types/matrix/common';
|
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
|
||||||
import { bytesToSize } from '../../../utils/common';
|
|
||||||
import {
|
|
||||||
READABLE_EXT_TO_MIME_TYPE,
|
|
||||||
READABLE_TEXT_MIME_TYPES,
|
|
||||||
getFileNameExt,
|
|
||||||
mimeTypeToExt,
|
|
||||||
} from '../../../utils/mimeTypes';
|
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
|
||||||
import {
|
|
||||||
decryptFile,
|
|
||||||
downloadEncryptedMedia,
|
|
||||||
downloadMedia,
|
|
||||||
mxcUrlToHttp,
|
|
||||||
} from '../../../utils/matrix';
|
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
|
||||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
|
||||||
import { ModalWide } from '../../../styles/Modal.css';
|
|
||||||
|
|
||||||
const renderErrorButton = (retry: () => void, text: string) => (
|
|
||||||
<TooltipProvider
|
|
||||||
tooltip={
|
|
||||||
<Tooltip variant="Critical">
|
|
||||||
<Text>Failed to load file!</Text>
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
position="Top"
|
|
||||||
align="Center"
|
|
||||||
>
|
|
||||||
{(triggerRef) => (
|
|
||||||
<Button
|
|
||||||
ref={triggerRef}
|
|
||||||
size="400"
|
|
||||||
variant="Critical"
|
|
||||||
fill="Soft"
|
|
||||||
outlined
|
|
||||||
radii="300"
|
|
||||||
onClick={retry}
|
|
||||||
before={<Icon size="100" src={Icons.Warning} filled />}
|
|
||||||
>
|
|
||||||
<Text size="B400" truncate>
|
|
||||||
{text}
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
type RenderTextViewerProps = {
|
|
||||||
name: string;
|
|
||||||
text: string;
|
|
||||||
langName: string;
|
|
||||||
requestClose: () => void;
|
|
||||||
};
|
|
||||||
type ReadTextFileProps = {
|
|
||||||
body: string;
|
|
||||||
mimeType: string;
|
|
||||||
url: string;
|
|
||||||
encInfo?: EncryptedAttachmentInfo;
|
|
||||||
renderViewer: (props: RenderTextViewerProps) => ReactNode;
|
|
||||||
};
|
|
||||||
export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: ReadTextFileProps) {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const useAuthentication = useMediaAuthentication();
|
|
||||||
const [textViewer, setTextViewer] = useState(false);
|
|
||||||
|
|
||||||
const [textState, loadText] = useAsyncCallback(
|
|
||||||
useCallback(async () => {
|
|
||||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
const fileContent = encInfo
|
|
||||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
|
||||||
: await downloadMedia(mediaUrl, accessToken);
|
|
||||||
|
|
||||||
const text = fileContent.text();
|
|
||||||
setTextViewer(true);
|
|
||||||
return text;
|
|
||||||
}, [mx, useAuthentication, mimeType, encInfo, url])
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{textState.status === AsyncStatus.Success && (
|
|
||||||
<Overlay open={textViewer} backdrop={<OverlayBackdrop />}>
|
|
||||||
<OverlayCenter>
|
|
||||||
<FocusTrap
|
|
||||||
focusTrapOptions={{
|
|
||||||
initialFocus: false,
|
|
||||||
onDeactivate: () => setTextViewer(false),
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Modal
|
|
||||||
className={ModalWide}
|
|
||||||
size="500"
|
|
||||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
|
||||||
>
|
|
||||||
{renderViewer({
|
|
||||||
name: body,
|
|
||||||
text: textState.data,
|
|
||||||
langName: READABLE_TEXT_MIME_TYPES.includes(mimeType)
|
|
||||||
? mimeTypeToExt(mimeType)
|
|
||||||
: mimeTypeToExt(READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)] ?? mimeType),
|
|
||||||
requestClose: () => setTextViewer(false),
|
|
||||||
})}
|
|
||||||
</Modal>
|
|
||||||
</FocusTrap>
|
|
||||||
</OverlayCenter>
|
|
||||||
</Overlay>
|
|
||||||
)}
|
|
||||||
{textState.status === AsyncStatus.Error ? (
|
|
||||||
renderErrorButton(loadText, 'Open File')
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="Secondary"
|
|
||||||
fill="Solid"
|
|
||||||
radii="300"
|
|
||||||
size="400"
|
|
||||||
onClick={() =>
|
|
||||||
textState.status === AsyncStatus.Success ? setTextViewer(true) : loadText()
|
|
||||||
}
|
|
||||||
disabled={textState.status === AsyncStatus.Loading}
|
|
||||||
before={
|
|
||||||
textState.status === AsyncStatus.Loading ? (
|
|
||||||
<Spinner fill="Solid" size="100" variant="Secondary" />
|
|
||||||
) : (
|
|
||||||
<Icon size="100" src={Icons.ArrowRight} filled />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text size="B400" truncate>
|
|
||||||
Open File
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type RenderPdfViewerProps = {
|
|
||||||
name: string;
|
|
||||||
src: string;
|
|
||||||
requestClose: () => void;
|
|
||||||
};
|
|
||||||
export type ReadPdfFileProps = {
|
|
||||||
body: string;
|
|
||||||
mimeType: string;
|
|
||||||
url: string;
|
|
||||||
encInfo?: EncryptedAttachmentInfo;
|
|
||||||
renderViewer: (props: RenderPdfViewerProps) => ReactNode;
|
|
||||||
};
|
|
||||||
export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: ReadPdfFileProps) {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const useAuthentication = useMediaAuthentication();
|
|
||||||
const [pdfViewer, setPdfViewer] = useState(false);
|
|
||||||
|
|
||||||
const [pdfState, loadPdf] = useAsyncCallback(
|
|
||||||
useCallback(async () => {
|
|
||||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
const fileContent = encInfo
|
|
||||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
|
||||||
: await downloadMedia(mediaUrl, accessToken);
|
|
||||||
setPdfViewer(true);
|
|
||||||
return URL.createObjectURL(fileContent);
|
|
||||||
}, [mx, url, useAuthentication, mimeType, encInfo])
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{pdfState.status === AsyncStatus.Success && (
|
|
||||||
<Overlay open={pdfViewer} backdrop={<OverlayBackdrop />}>
|
|
||||||
<OverlayCenter>
|
|
||||||
<FocusTrap
|
|
||||||
focusTrapOptions={{
|
|
||||||
initialFocus: false,
|
|
||||||
onDeactivate: () => setPdfViewer(false),
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Modal
|
|
||||||
className={ModalWide}
|
|
||||||
size="500"
|
|
||||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
|
||||||
>
|
|
||||||
{renderViewer({
|
|
||||||
name: body,
|
|
||||||
src: pdfState.data,
|
|
||||||
requestClose: () => setPdfViewer(false),
|
|
||||||
})}
|
|
||||||
</Modal>
|
|
||||||
</FocusTrap>
|
|
||||||
</OverlayCenter>
|
|
||||||
</Overlay>
|
|
||||||
)}
|
|
||||||
{pdfState.status === AsyncStatus.Error ? (
|
|
||||||
renderErrorButton(loadPdf, 'Open PDF')
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="Secondary"
|
|
||||||
fill="Solid"
|
|
||||||
radii="300"
|
|
||||||
size="400"
|
|
||||||
onClick={() => (pdfState.status === AsyncStatus.Success ? setPdfViewer(true) : loadPdf())}
|
|
||||||
disabled={pdfState.status === AsyncStatus.Loading}
|
|
||||||
before={
|
|
||||||
pdfState.status === AsyncStatus.Loading ? (
|
|
||||||
<Spinner fill="Solid" size="100" variant="Secondary" />
|
|
||||||
) : (
|
|
||||||
<Icon size="100" src={Icons.ArrowRight} filled />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text size="B400" truncate>
|
|
||||||
Open PDF
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DownloadFileProps = {
|
|
||||||
body: string;
|
|
||||||
mimeType: string;
|
|
||||||
url: string;
|
|
||||||
info: IFileInfo;
|
|
||||||
encInfo?: EncryptedAttachmentInfo;
|
|
||||||
};
|
|
||||||
export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const useAuthentication = useMediaAuthentication();
|
|
||||||
|
|
||||||
const [downloadState, download] = useAsyncCallback(
|
|
||||||
useCallback(async () => {
|
|
||||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
const fileContent = encInfo
|
|
||||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
|
||||||
: await downloadMedia(mediaUrl, accessToken);
|
|
||||||
|
|
||||||
await saveMediaBlob(fileContent, body);
|
|
||||||
return fileContent;
|
|
||||||
}, [mx, url, useAuthentication, mimeType, encInfo, body])
|
|
||||||
);
|
|
||||||
|
|
||||||
return downloadState.status === AsyncStatus.Error ? (
|
|
||||||
renderErrorButton(download, `Retry Download (${bytesToSize(info.size ?? 0)})`)
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="Secondary"
|
|
||||||
fill="Soft"
|
|
||||||
radii="300"
|
|
||||||
size="400"
|
|
||||||
onClick={() =>
|
|
||||||
downloadState.status === AsyncStatus.Success
|
|
||||||
? saveMediaBlob(downloadState.data, body)
|
|
||||||
: download()
|
|
||||||
}
|
|
||||||
disabled={downloadState.status === AsyncStatus.Loading}
|
|
||||||
before={
|
|
||||||
downloadState.status === AsyncStatus.Loading ? (
|
|
||||||
<Spinner fill="Soft" size="100" variant="Secondary" />
|
|
||||||
) : (
|
|
||||||
<Icon size="100" src={Icons.Download} filled />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text size="B400" truncate>{`Download (${bytesToSize(info.size ?? 0)})`}</Text>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type FileContentProps = {
|
|
||||||
body: string;
|
|
||||||
mimeType: string;
|
|
||||||
renderAsTextFile: () => ReactNode;
|
|
||||||
renderAsPdfFile: () => ReactNode;
|
|
||||||
};
|
|
||||||
export const FileContent = as<'div', FileContentProps>(
|
|
||||||
({ body, mimeType, renderAsTextFile, renderAsPdfFile, children, ...props }, ref) => (
|
|
||||||
<Box direction="Column" gap="300" {...props} ref={ref}>
|
|
||||||
{(READABLE_TEXT_MIME_TYPES.includes(mimeType) ||
|
|
||||||
READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)]) &&
|
|
||||||
renderAsTextFile()}
|
|
||||||
{mimeType === 'application/pdf' && renderAsPdfFile()}
|
|
||||||
{children}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
@@ -45,22 +45,42 @@ type MobileSwipeGestureHostProps = {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const IGNORE_BACK_SELECTOR =
|
||||||
|
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-back="true"]';
|
||||||
|
|
||||||
|
const IGNORE_REPLY_SELECTOR =
|
||||||
|
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-reply="true"]';
|
||||||
|
|
||||||
|
/** True when the touch starts on (or inside) a horizontally scrollable region like an image carousel. */
|
||||||
|
const isInsideHorizontalScroller = (target: EventTarget | null): boolean => {
|
||||||
|
if (!(target instanceof Element)) return false;
|
||||||
|
|
||||||
|
let el: Element | null = target;
|
||||||
|
while (el && el !== document.documentElement) {
|
||||||
|
if (el instanceof HTMLElement) {
|
||||||
|
const style = window.getComputedStyle(el);
|
||||||
|
const overflowX = style.overflowX;
|
||||||
|
if (
|
||||||
|
(overflowX === 'auto' || overflowX === 'scroll' || overflowX === 'overlay') &&
|
||||||
|
el.scrollWidth > el.clientWidth + 1
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const shouldIgnoreBackTarget = (target: EventTarget | null): boolean => {
|
const shouldIgnoreBackTarget = (target: EventTarget | null): boolean => {
|
||||||
if (!(target instanceof Element)) return true;
|
if (!(target instanceof Element)) return true;
|
||||||
return Boolean(
|
return Boolean(target.closest(IGNORE_BACK_SELECTOR)) || isInsideHorizontalScroller(target);
|
||||||
target.closest(
|
|
||||||
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-back="true"]'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const shouldIgnoreReplyTarget = (target: EventTarget | null): boolean => {
|
const shouldIgnoreReplyTarget = (target: EventTarget | null): boolean => {
|
||||||
if (!(target instanceof Element)) return true;
|
if (!(target instanceof Element)) return true;
|
||||||
return Boolean(
|
return Boolean(target.closest(IGNORE_REPLY_SELECTOR)) || isInsideHorizontalScroller(target);
|
||||||
target.closest(
|
|
||||||
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-reply="true"]'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const findMessageElement = (target: EventTarget | null): HTMLElement | null => {
|
const findMessageElement = (target: EventTarget | null): HTMLElement | null => {
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { Box, Chip, Header, IconButton, Text, as } from 'folds';
|
|
||||||
import { Icon, Icons } from '../icons';
|
|
||||||
import * as css from './VideoViewer.css';
|
|
||||||
import { downloadAndSaveMedia } from '../../utils/saveMedia';
|
|
||||||
import { getCurrentAccessToken } from '../../utils/auth';
|
|
||||||
|
|
||||||
export type VideoViewerProps = {
|
|
||||||
alt: string;
|
|
||||||
src: string;
|
|
||||||
requestClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const VideoViewer = as<'div', VideoViewerProps>(
|
|
||||||
({ className, alt, src, requestClose, ...props }, ref) => {
|
|
||||||
const handleDownload = async () => {
|
|
||||||
try {
|
|
||||||
await downloadAndSaveMedia(src, alt, getCurrentAccessToken());
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[VideoViewer] Failed to download media:', error);
|
|
||||||
try {
|
|
||||||
window.open(src, '_blank');
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
className={classNames(css.VideoViewer, className)}
|
|
||||||
direction="Column"
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
>
|
|
||||||
<Header className={css.VideoViewerHeader} size="400">
|
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
|
||||||
<IconButton size="300" radii="300" onClick={requestClose}>
|
|
||||||
<Icon size="50" src={Icons.ArrowLeft} />
|
|
||||||
</IconButton>
|
|
||||||
<Text size="T300" truncate>
|
|
||||||
{alt}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Box shrink="No" alignItems="Center" gap="200">
|
|
||||||
<Chip
|
|
||||||
variant="Primary"
|
|
||||||
onClick={handleDownload}
|
|
||||||
radii="300"
|
|
||||||
before={<Icon size="50" src={Icons.Download} />}
|
|
||||||
>
|
|
||||||
<Text size="B300">Download</Text>
|
|
||||||
</Chip>
|
|
||||||
</Box>
|
|
||||||
</Header>
|
|
||||||
<Box
|
|
||||||
grow="Yes"
|
|
||||||
className={css.VideoViewerContent}
|
|
||||||
justifyContent="Center"
|
|
||||||
alignItems="Center"
|
|
||||||
>
|
|
||||||
<video
|
|
||||||
className={css.VideoViewerVideo}
|
|
||||||
src={src}
|
|
||||||
title={alt}
|
|
||||||
controls
|
|
||||||
autoPlay
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -171,6 +171,8 @@ type PushStatus = {
|
|||||||
distributor: string;
|
distributor: string;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
distributors: string[];
|
distributors: string[];
|
||||||
|
lastFailure: string;
|
||||||
|
autoStartBlocked: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Android-only section showing UnifiedPush registration status and controls. */
|
/** Android-only section showing UnifiedPush registration status and controls. */
|
||||||
@@ -194,6 +196,8 @@ function AndroidPushNotifications() {
|
|||||||
distributor: s.distributor || '',
|
distributor: s.distributor || '',
|
||||||
endpoint: s.endpoint || '',
|
endpoint: s.endpoint || '',
|
||||||
distributors,
|
distributors,
|
||||||
|
lastFailure: s.lastFailure || '',
|
||||||
|
autoStartBlocked: Boolean(s.autoStartBlocked),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
);
|
);
|
||||||
@@ -211,7 +215,7 @@ function AndroidPushNotifications() {
|
|||||||
await refresh();
|
await refresh();
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setLastError(
|
setLastError(
|
||||||
'Could not register a push distributor. Install ntfy (with UnifiedPush enabled), then try Reset again.'
|
'Selected distributor but did not get a push endpoint. In ntfy: enable UnifiedPush, allow unrestricted battery, then try Reset again.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [refresh])
|
}, [refresh])
|
||||||
@@ -244,6 +248,20 @@ function AndroidPushNotifications() {
|
|||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (status.lastFailure === 'AUTO_START_BLOCKED' || status.autoStartBlocked) {
|
||||||
|
return (
|
||||||
|
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||||
|
Phone is blocking auto-start for Paarrot. Open App Boot / Auto-start settings, allow Paarrot (and ntfy), then tap Reset.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status.lastFailure) {
|
||||||
|
return (
|
||||||
|
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||||
|
{`Registration failed (${status.lastFailure}). Tap Reset and pick ntfy again.`}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (status.distributors.length === 0) {
|
if (status.distributors.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||||
|
|||||||
49
overlay/src/app/hooks/usePathWithOrigin.ts
Normal file
49
overlay/src/app/hooks/usePathWithOrigin.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useClientConfig } from './useClientConfig';
|
||||||
|
import { trimLeadingSlash, trimSlash, trimTrailingSlash } from '../utils/common';
|
||||||
|
import { isCapacitorNative, isElectron } from '../utils/tauri';
|
||||||
|
|
||||||
|
/** Authority shown on Matrix SSO consent ("Continue to paarrot://desktop"). */
|
||||||
|
export const ELECTRON_PROTOCOL_HOST = 'desktop';
|
||||||
|
|
||||||
|
/** Authority shown on Matrix SSO consent for the Android Capacitor app. */
|
||||||
|
export const ANDROID_PROTOCOL_HOST = 'android';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an absolute URL for the given in-app path.
|
||||||
|
*
|
||||||
|
* In Electron / Capacitor, SSO (and other browser handoffs) must return via
|
||||||
|
* paarrot://{host}/... — a named host so consent UI identifies the app, and a
|
||||||
|
* real pathname (not a hash) because browsers strip fragments when launching
|
||||||
|
* custom protocols.
|
||||||
|
*/
|
||||||
|
export const usePathWithOrigin = (path: string): string => {
|
||||||
|
const { hashRouter } = useClientConfig();
|
||||||
|
const { origin } = window.location;
|
||||||
|
|
||||||
|
const pathWithOrigin = useMemo(() => {
|
||||||
|
if (isElectron() || isCapacitorNative()) {
|
||||||
|
const host = isElectron() ? ELECTRON_PROTOCOL_HOST : ANDROID_PROTOCOL_HOST;
|
||||||
|
const basename = trimSlash(hashRouter?.basename ?? '');
|
||||||
|
const routeParts = [basename, trimLeadingSlash(path)].filter(Boolean);
|
||||||
|
const route = `/${routeParts.join('/')}`.replace(/\/{2,}/g, '/');
|
||||||
|
return `paarrot://${host}${route}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let url: string = trimSlash(origin);
|
||||||
|
|
||||||
|
url += `/${trimSlash(import.meta.env.BASE_URL ?? '')}`;
|
||||||
|
url = trimTrailingSlash(url);
|
||||||
|
|
||||||
|
if (hashRouter?.enabled) {
|
||||||
|
url += `/#/${trimSlash(hashRouter.basename ?? '')}`;
|
||||||
|
url = trimTrailingSlash(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
url += `/${trimLeadingSlash(path)}`;
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}, [path, hashRouter, origin]);
|
||||||
|
|
||||||
|
return pathWithOrigin;
|
||||||
|
};
|
||||||
@@ -121,7 +121,8 @@ function notificationBodyFromEvent(mEvent: MatrixEvent): string | undefined {
|
|||||||
const rawBody = typeof content.body === 'string' ? content.body : undefined;
|
const rawBody = typeof content.body === 'string' ? content.body : undefined;
|
||||||
switch (content.msgtype) {
|
switch (content.msgtype) {
|
||||||
case 'm.image':
|
case 'm.image':
|
||||||
return rawBody ? `📷 ${rawBody}` : '📷 Photo';
|
// Matrix body is usually a filename — skip it in the shade.
|
||||||
|
return '📷 Photo';
|
||||||
case 'm.video':
|
case 'm.video':
|
||||||
return rawBody ? `🎥 ${rawBody}` : '🎥 Video';
|
return rawBody ? `🎥 ${rawBody}` : '🎥 Video';
|
||||||
case 'm.audio':
|
case 'm.audio':
|
||||||
@@ -129,7 +130,7 @@ function notificationBodyFromEvent(mEvent: MatrixEvent): string | undefined {
|
|||||||
case 'm.file':
|
case 'm.file':
|
||||||
return rawBody ? `📎 ${rawBody}` : '📎 File';
|
return rawBody ? `📎 ${rawBody}` : '📎 File';
|
||||||
case 'm.sticker':
|
case 'm.sticker':
|
||||||
return rawBody ? `🖼️ ${rawBody}` : '🖼️ Sticker';
|
return '🖼️ Sticker';
|
||||||
default:
|
default:
|
||||||
return rawBody;
|
return rawBody;
|
||||||
}
|
}
|
||||||
|
|||||||
127
overlay/src/app/utils/androidDeepLink.ts
Normal file
127
overlay/src/app/utils/androidDeepLink.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { App } from '@capacitor/app';
|
||||||
|
import { Browser } from '@capacitor/browser';
|
||||||
|
import { isCapacitorNative } from './tauri';
|
||||||
|
|
||||||
|
const PROTOCOL_SCHEME = 'paarrot';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a paarrot:// deep link into an in-app hash route.
|
||||||
|
* Supports:
|
||||||
|
* - Hash routes: paarrot://anything/#/login/server/
|
||||||
|
* - Path routes (SSO-safe): paarrot://android/login/server/?loginToken=...
|
||||||
|
* Preserves SSO loginToken from the URL query (Synapse inserts it before `#`).
|
||||||
|
*/
|
||||||
|
export function deepLinkToHashRoute(deepLink: string): string | null {
|
||||||
|
if (!deepLink) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deepLink.toLowerCase().startsWith(`${PROTOCOL_SCHEME}://`)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hashRoute: string | null = null;
|
||||||
|
let loginToken: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(deepLink);
|
||||||
|
loginToken = parsed.searchParams.get('loginToken');
|
||||||
|
|
||||||
|
if (parsed.hash && parsed.hash.length > 1) {
|
||||||
|
const hashValue = parsed.hash.slice(1);
|
||||||
|
hashRoute = hashValue.startsWith('/') ? hashValue : `/${hashValue}`;
|
||||||
|
} else if (parsed.pathname && parsed.pathname !== '/') {
|
||||||
|
// paarrot://android/login/... → /login/...
|
||||||
|
hashRoute = parsed.pathname;
|
||||||
|
if (parsed.search && parsed.search.length > 1) {
|
||||||
|
const params = new URLSearchParams(parsed.search);
|
||||||
|
params.delete('loginToken');
|
||||||
|
const rest = params.toString();
|
||||||
|
if (rest) {
|
||||||
|
hashRoute += `?${rest}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
const hashIndex = deepLink.indexOf('#');
|
||||||
|
if (hashIndex !== -1) {
|
||||||
|
const hashValue = deepLink.slice(hashIndex + 1);
|
||||||
|
if (hashValue) {
|
||||||
|
hashRoute = hashValue.startsWith('/') ? hashValue : `/${hashValue}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = deepLink.match(/[?&]loginToken=([^&#]+)/i);
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
loginToken = decodeURIComponent(match[1]);
|
||||||
|
} catch {
|
||||||
|
loginToken = match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loginToken) {
|
||||||
|
if (!hashRoute) {
|
||||||
|
hashRoute = '/';
|
||||||
|
}
|
||||||
|
const queryIndex = hashRoute.indexOf('?');
|
||||||
|
const pathPart = queryIndex === -1 ? hashRoute : hashRoute.slice(0, queryIndex);
|
||||||
|
const params = new URLSearchParams(queryIndex === -1 ? '' : hashRoute.slice(queryIndex + 1));
|
||||||
|
if (!params.has('loginToken')) {
|
||||||
|
params.set('loginToken', loginToken);
|
||||||
|
}
|
||||||
|
const query = params.toString();
|
||||||
|
hashRoute = query ? `${pathPart}?${query}` : pathPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hashRoute;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyDeepLink(deepLink: string): Promise<void> {
|
||||||
|
const hashRoute = deepLinkToHashRoute(deepLink);
|
||||||
|
if (!hashRoute) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Browser.close();
|
||||||
|
} catch {
|
||||||
|
// Browser may already be closed or never opened.
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextHash = hashRoute.startsWith('/') ? `#${hashRoute}` : `#/${hashRoute}`;
|
||||||
|
if (window.location.hash === nextHash) {
|
||||||
|
// Force TokenLogin to re-run if we land on the same route again.
|
||||||
|
window.location.hash = '#/';
|
||||||
|
window.setTimeout(() => {
|
||||||
|
window.location.hash = nextHash;
|
||||||
|
}, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.hash = nextHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen for paarrot:// SSO returns (and cold-start launch URLs) on Capacitor Android.
|
||||||
|
*/
|
||||||
|
export async function initAndroidDeepLinks(): Promise<void> {
|
||||||
|
if (!isCapacitorNative()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const launch = await App.getLaunchUrl();
|
||||||
|
if (launch?.url) {
|
||||||
|
await applyDeepLink(launch.url);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[androidDeepLink] Failed to read launch URL:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await App.addListener('appUrlOpen', ({ url }) => {
|
||||||
|
if (!url) return;
|
||||||
|
void applyDeepLink(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ type UnifiedPushStatus = {
|
|||||||
registered: boolean;
|
registered: boolean;
|
||||||
distributor: string;
|
distributor: string;
|
||||||
distributors: string[] | string;
|
distributors: string[] | string;
|
||||||
|
lastFailure?: string;
|
||||||
|
/** OEM App Boot / AUTO_START is blocking distributor broadcasts (e.g. TCL). */
|
||||||
|
autoStartBlocked?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ClearRoomNotificationsResult = {
|
type ClearRoomNotificationsResult = {
|
||||||
@@ -551,16 +554,20 @@ export const requestResetPushRegistration = async (): Promise<{ success: boolean
|
|||||||
if (!result?.success) return { success: false };
|
if (!result?.success) return { success: false };
|
||||||
|
|
||||||
// Endpoint arrives asynchronously from the distributor after register().
|
// Endpoint arrives asynchronously from the distributor after register().
|
||||||
for (let i = 0; i < 20; i += 1) {
|
for (let i = 0; i < 40; i += 1) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
const status = await getBackgroundSyncStatus();
|
const status = await getBackgroundSyncStatus();
|
||||||
if (status?.registered && status.endpoint) {
|
if (status?.registered && status.endpoint) {
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
if (status?.lastFailure) {
|
||||||
|
console.warn('[BackgroundSync] Registration failed while waiting:', status.lastFailure);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Picker succeeded but endpoint not yet ack'd — still treat as success.
|
console.warn('[BackgroundSync] Distributor selected but no endpoint received in time');
|
||||||
return { success: true };
|
return { success: false };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[BackgroundSync] requestDistributorSetup failed:', err);
|
console.error('[BackgroundSync] requestDistributorSetup failed:', err);
|
||||||
return { success: false };
|
return { success: false };
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import FileSaver from 'file-saver';
|
|
||||||
import { Capacitor, registerPlugin } from '@capacitor/core';
|
import { Capacitor, registerPlugin } from '@capacitor/core';
|
||||||
import { downloadMedia } from './matrix';
|
import { registerMediaSaver } from './saveMedia';
|
||||||
import { getCurrentAccessToken } from './auth';
|
|
||||||
|
|
||||||
interface AndroidShareHandlerSavePlugin {
|
interface AndroidShareHandlerSavePlugin {
|
||||||
saveFile(options: {
|
saveFile(options: {
|
||||||
@@ -9,19 +7,10 @@ interface AndroidShareHandlerSavePlugin {
|
|||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
base64: string;
|
base64: string;
|
||||||
}): Promise<{ saved: boolean; shared?: boolean; uri?: string }>;
|
}): Promise<{ saved: boolean; shared?: boolean; uri?: string }>;
|
||||||
shareFile(options: {
|
|
||||||
filename: string;
|
|
||||||
mimeType?: string;
|
|
||||||
base64: string;
|
|
||||||
}): Promise<{ shared: boolean }>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AndroidShareHandler = registerPlugin<AndroidShareHandlerSavePlugin>('AndroidShareHandler');
|
const AndroidShareHandler = registerPlugin<AndroidShareHandlerSavePlugin>('AndroidShareHandler');
|
||||||
|
|
||||||
const isAndroidNative = (): boolean =>
|
|
||||||
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
|
|
||||||
|
|
||||||
/** Convert a Blob to raw base64 (no data: prefix). */
|
|
||||||
const blobToBase64 = (blob: Blob): Promise<string> =>
|
const blobToBase64 = (blob: Blob): Promise<string> =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@@ -62,42 +51,17 @@ const guessMimeFromName = (filename: string): string | undefined => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist a Blob to disk. On Capacitor Android, uses MediaStore via the native
|
* Wire Capacitor Android MediaStore saves into the core saveMedia helpers.
|
||||||
* ShareHandler plugin (FileSaver / `<a download>` does not work in WebView).
|
* Call once at app startup from the mobile overlay entry.
|
||||||
* Elsewhere falls back to FileSaver.
|
|
||||||
*/
|
*/
|
||||||
export const saveMediaBlob = async (blob: Blob, filename: string): Promise<void> => {
|
export function registerAndroidMediaSaver(): void {
|
||||||
if (isAndroidNative()) {
|
if (!(Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerMediaSaver(async (blob, filename) => {
|
||||||
const base64 = await blobToBase64(blob);
|
const base64 = await blobToBase64(blob);
|
||||||
const mimeType = blob.type || guessMimeFromName(filename) || 'application/octet-stream';
|
const mimeType = blob.type || guessMimeFromName(filename) || 'application/octet-stream';
|
||||||
await AndroidShareHandler.saveFile({ filename, mimeType, base64 });
|
await AndroidShareHandler.saveFile({ filename, mimeType, base64 });
|
||||||
return;
|
});
|
||||||
}
|
}
|
||||||
FileSaver.saveAs(blob, filename);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch media (auth download or blob:/http URL) and save it via [saveMediaBlob].
|
|
||||||
*/
|
|
||||||
export const downloadAndSaveMedia = async (
|
|
||||||
src: string,
|
|
||||||
filename: string,
|
|
||||||
accessToken?: string | null
|
|
||||||
): Promise<void> => {
|
|
||||||
let blob: Blob;
|
|
||||||
try {
|
|
||||||
if (src.startsWith('blob:') || src.startsWith('data:')) {
|
|
||||||
const res = await fetch(src);
|
|
||||||
if (!res.ok) throw new Error(`Failed to fetch ${src}`);
|
|
||||||
blob = await res.blob();
|
|
||||||
} else {
|
|
||||||
blob = await downloadMedia(src, accessToken ?? getCurrentAccessToken());
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[saveMedia] downloadMedia failed, trying fetch fallback:', error);
|
|
||||||
const res = await fetch(src);
|
|
||||||
if (!res.ok) throw error;
|
|
||||||
blob = await res.blob();
|
|
||||||
}
|
|
||||||
await saveMediaBlob(blob, filename);
|
|
||||||
};
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
export const openExternalUrl = async (url: string): Promise<void> => {
|
export const openExternalUrl = async (url: string): Promise<void> => {
|
||||||
console.log('[openExternalUrl] called with:', url);
|
console.log('[openExternalUrl] called with:', url);
|
||||||
|
|
||||||
// Use Electron's shell.openExternal if in Electron
|
// Use Electron's shell.openExternal if in Electron
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
try {
|
try {
|
||||||
@@ -18,7 +18,20 @@ export const openExternalUrl = async (url: string): Promise<void> => {
|
|||||||
console.warn('[openExternalUrl] Electron shell.openExternal failed:', err);
|
console.warn('[openExternalUrl] Electron shell.openExternal failed:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capacitor: SSO (and other handoffs) need Chrome Custom Tabs / in-app Browser
|
||||||
|
// so paarrot:// redirects return into the app instead of a blank external tab.
|
||||||
|
if (isCapacitorNative()) {
|
||||||
|
try {
|
||||||
|
const { Browser } = await import('@capacitor/browser');
|
||||||
|
await Browser.open({ url, presentationStyle: 'popover' });
|
||||||
|
console.log('[openExternalUrl] Capacitor Browser.open succeeded');
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[openExternalUrl] Capacitor Browser.open failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Use Tauri for actual Tauri builds
|
// Use Tauri for actual Tauri builds
|
||||||
if (isTauri() && !isElectron()) {
|
if (isTauri() && !isElectron()) {
|
||||||
try {
|
try {
|
||||||
@@ -30,7 +43,7 @@ export const openExternalUrl = async (url: string): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
} catch (pluginErr) {
|
} catch (pluginErr) {
|
||||||
console.warn('[openExternalUrl] Tauri opener plugin failed:', pluginErr);
|
console.warn('[openExternalUrl] Tauri opener plugin failed:', pluginErr);
|
||||||
|
|
||||||
// Fallback: try the custom command (useful if plugin fails due to ACL)
|
// Fallback: try the custom command (useful if plugin fails due to ACL)
|
||||||
try {
|
try {
|
||||||
console.log('[openExternalUrl] trying Tauri invoke command fallback...');
|
console.log('[openExternalUrl] trying Tauri invoke command fallback...');
|
||||||
@@ -43,7 +56,7 @@ export const openExternalUrl = async (url: string): Promise<void> => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[openExternalUrl] falling back to window.open');
|
console.log('[openExternalUrl] falling back to window.open');
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { trimTrailingSlash } from './app/utils/common';
|
|||||||
import App from './app/pages/App';
|
import App from './app/pages/App';
|
||||||
import { applySafeAreaInsets, isTauri } from './app/utils/tauri';
|
import { applySafeAreaInsets, isTauri } from './app/utils/tauri';
|
||||||
import { enableViewTransitionsForNavigation } from './app/utils/viewTransitions';
|
import { enableViewTransitionsForNavigation } from './app/utils/viewTransitions';
|
||||||
|
import { initAndroidDeepLinks } from './app/utils/androidDeepLink';
|
||||||
|
import { registerAndroidMediaSaver } from './app/utils/registerAndroidMediaSaver';
|
||||||
|
|
||||||
// import i18n (needs to be bundled ;))
|
// import i18n (needs to be bundled ;))
|
||||||
import './app/i18n';
|
import './app/i18n';
|
||||||
@@ -28,6 +30,12 @@ applySafeAreaInsets();
|
|||||||
// Enable View Transitions API for smooth navigation
|
// Enable View Transitions API for smooth navigation
|
||||||
enableViewTransitionsForNavigation();
|
enableViewTransitionsForNavigation();
|
||||||
|
|
||||||
|
// Capacitor: paarrot:// SSO return closes Custom Tabs and lands on loginToken
|
||||||
|
void initAndroidDeepLinks();
|
||||||
|
|
||||||
|
// Capacitor Android: MediaStore saves for images / videos / files
|
||||||
|
registerAndroidMediaSaver();
|
||||||
|
|
||||||
// Register Service Worker
|
// Register Service Worker
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
const swUrl =
|
const swUrl =
|
||||||
|
|||||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -1,15 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.121",
|
"version": "4.11.150",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.121",
|
"version": "4.11.150",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.3.0",
|
"@capacitor/android": "8.3.0",
|
||||||
|
"@capacitor/app": "8.0.1",
|
||||||
|
"@capacitor/browser": "8.0.1",
|
||||||
"@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",
|
||||||
@@ -33,6 +35,24 @@
|
|||||||
"@capacitor/core": "^8.3.0"
|
"@capacitor/core": "^8.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@capacitor/app": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-yeG3yyA0ETKqvgqexwHMBlmVOF13A1hRXzv/km0Ptv5TrNIZvZJK4MTI3uiqvnbHrzoJGP5DwWAjEXEfi90v3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@capacitor/browser": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@capacitor/browser/-/browser-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-cCEYK9DGJvamfCKOh95QP+VafApblySDR/eodOcl1Qt+d/Y+NNPzc8NYm/dztQlRf3aMLJ7ne1HrRCfu2UOMnw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@capacitor/cli": {
|
"node_modules/@capacitor/cli": {
|
||||||
"version": "8.3.0",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.3.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.142",
|
"version": "4.11.156",
|
||||||
"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"
|
||||||
@@ -23,6 +23,8 @@
|
|||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.3.0",
|
"@capacitor/android": "8.3.0",
|
||||||
|
"@capacitor/app": "8.0.1",
|
||||||
|
"@capacitor/browser": "8.0.1",
|
||||||
"@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",
|
||||||
|
|||||||
Reference in New Issue
Block a user