Compare commits

..

21 Commits

Author SHA1 Message Date
GitHub Actions
aada77f30f chore: bump version to 4.11.152 [skip ci] 2026-08-09 10:49:13 +00:00
bb768911ef Wire Android SSO Custom Tabs, carousel-safe swipes, and core saveMedia.
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m41s
Open SSO in Capacitor Browser with paarrot://android return; stop swipe-to-reply stealing carousel scrolls; bump cinny for shared save helpers and register MediaStore from the overlay.
2026-08-09 20:49:05 +10:00
GitHub Actions
a1c6015772 chore: bump version to 4.11.151 [skip ci] 2026-08-09 09:42:28 +00:00
d7d7d183c5 Stop Android overlay from replacing the image shadowbox.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 5m36s
2026-08-09 19:42:20 +10:00
GitHub Actions
a5ddd10ff4 chore: bump version to 4.11.150 [skip ci] 2026-08-09 08:35:33 +00:00
GitHub Actions
2921d3a547 Bump cinny: Make sick emoji confetti a fullscreen downward spew with morph.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 7m20s
2026-08-09 08:35:27 +00:00
GitHub Actions
2be4c66b4a chore: bump version to 4.11.149 [skip ci] 2026-08-09 05:59:09 +00:00
GitHub Actions
f180f4ab7c Bump cinny: Replace image lightbox with slate-style shadowbox.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 6m48s
2026-08-09 05:59:01 +00:00
GitHub Actions
b1167c3eb6 chore: bump version to 4.11.148 [skip ci] 2026-08-09 05:50:36 +00:00
GitHub Actions
16bdf92f77 Bump cinny: Add fullscreen firework emoji confetti and fix Linux URL paste.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 7m16s
2026-08-09 05:50:28 +00:00
GitHub Actions
2ebe97c5a4 chore: bump version to 4.11.147 [skip ci] 2026-08-09 03:17:01 +00:00
GitHub Actions
1aef7812a1 Bump cinny: Add bot-driven room app UI via im.paarrot.ui state.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 7m55s
2026-08-09 03:16:55 +00:00
GitHub Actions
4f61a31f28 chore: bump version to 4.11.146 [skip ci] 2026-08-03 06:32:44 +00:00
491a5845f0 Trigger builds from PAT submodule bumps and allow manual dispatch.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 5m8s
2026-08-03 16:32:38 +10:00
GitHub Actions
bd25ce8e1d Bump cinny: Use ACTIONS_TOKEN secret to dispatch cinny-mobile builds. 2026-08-03 06:31:00 +00:00
a5d44e191b Fix update-submodule workflow_dispatch input YAML quoting. [skip ci] 2026-08-03 16:30:49 +10:00
5f4bf191fc Add workflow to bump cinny submodule from workflow_dispatch. [skip ci] 2026-08-03 16:28:44 +10:00
GitHub Actions
cfcec87cd9 chore: bump version to 4.11.145 [skip ci] 2026-07-29 10:41:28 +00:00
2c9bcca344 Bump cinny: emoji confetti bursts and component playground.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 5m35s
2026-07-29 20:41:21 +10:00
GitHub Actions
9bb7adafd0 chore: bump version to 4.11.144 [skip ci] 2026-07-27 16:39:51 +00:00
c9c7bdb48d Keep stacked notification images and warn when OEM AUTO_START blocks UnifiedPush.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 4m35s
Attach image thumbnails via MessagingStyle message data so they survive multi-message history, drop useless image filenames, and surface TCL App Boot blocks in settings.
2026-07-28 02:39:44 +10:00
25 changed files with 530 additions and 918 deletions

View File

@@ -7,6 +7,7 @@ on:
- dev
tags:
- 'v*'
workflow_dispatch:
jobs:
increment-version:

View 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

View File

@@ -9,7 +9,10 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-browser')
implementation project(':capacitor-local-notifications')
implementation project(':capacitor-status-bar')
}

View File

@@ -40,6 +40,14 @@
<data android:mimeType="*/*" />
</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>
<provider

View File

@@ -21,6 +21,7 @@ import android.graphics.PorterDuffXfermode
import android.graphics.Rect
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.content.FileProvider
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
@@ -33,6 +34,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
@@ -274,19 +277,17 @@ class MatrixSyncService : Service() {
val body = when {
eventType == "m.room.encrypted" -> "Encrypted message"
eventType == "m.sticker" -> {
val raw = content.optString("body")
if (raw.isNotBlank()) "🖼️ $raw" else "🖼️ Sticker"
}
eventType == "m.sticker" -> "🖼️ Sticker"
else -> {
val msgtype = content.optString("msgtype")
val rawBody = content.optString("body")
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.audio" -> if (rawBody.isNotBlank()) "🎵 $rawBody" else "🎵 Audio"
"m.file" -> if (rawBody.isNotBlank()) "📎 $rawBody" else "📎 File"
"m.sticker" -> if (rawBody.isNotBlank()) "🖼️ $rawBody" else "🖼️ Sticker"
"m.sticker" -> "🖼️ Sticker"
else -> rawBody.takeIf { it.isNotBlank() }
}
}
@@ -836,10 +837,56 @@ class MatrixSyncService : Service() {
val sender: String,
val text: String,
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 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> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(historyPrefsKey(roomId), null)
@@ -852,8 +899,9 @@ class MatrixSyncService : Service() {
val sender = obj.optString("sender")
val text = obj.optString("text")
val ts = obj.optLong("ts", 0L)
val imageUri = obj.optString("imageUri").takeIf { it.isNotBlank() }
if (sender.isBlank() || text.isBlank()) continue
add(PendingNotifMessage(sender, text, ts))
add(PendingNotifMessage(sender, text, ts, imageUri))
}
}
} catch (e: Exception) {
@@ -869,12 +917,14 @@ class MatrixSyncService : Service() {
) {
val arr = JSONArray()
for (msg in messages) {
arr.put(
JSONObject()
.put("sender", msg.sender)
.put("text", msg.text)
.put("ts", msg.timestamp)
)
val obj = JSONObject()
.put("sender", msg.sender)
.put("text", msg.text)
.put("ts", msg.timestamp)
if (!msg.imageUri.isNullOrBlank()) {
obj.put("imageUri", msg.imageUri)
}
arr.put(obj)
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
@@ -883,6 +933,9 @@ class MatrixSyncService : Service() {
}
private fun clearMessageHistory(context: Context, roomId: String) {
for (msg in loadMessageHistory(context, roomId)) {
deleteNotificationImageUri(context, msg.imageUri)
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.remove(historyPrefsKey(roomId))
@@ -894,12 +947,19 @@ class MatrixSyncService : Service() {
roomId: String,
sender: String,
text: String,
imageUri: String? = null,
): List<PendingNotifMessage> {
val next = (loadMessageHistory(context, roomId) + PendingNotifMessage(
val previous = loadMessageHistory(context, roomId)
val next = (previous + PendingNotifMessage(
sender = sender,
text = text,
timestamp = System.currentTimeMillis(),
imageUri = imageUri,
)).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)
return next
}
@@ -967,7 +1027,14 @@ class MatrixSyncService : Service() {
}
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 collapsedTitle = if (isDm || resolvedConversation.isNullOrBlank()) {
@@ -1008,13 +1075,17 @@ class MatrixSyncService : Service() {
.setImportant(true)
.build()
}
messagingStyle.addMessage(
NotificationCompat.MessagingStyle.Message(
msg.text,
msg.timestamp,
person,
)
val line = NotificationCompat.MessagingStyle.Message(
msg.text,
msg.timestamp,
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)
@@ -1044,17 +1115,6 @@ class MatrixSyncService : Service() {
// Collapsed shade avatar (large icon). Small icon must stay the monochrome app mark.
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())
val summaryId = notificationIdForGroupSummary(groupId)

View File

@@ -2,10 +2,13 @@ package com.paarrot.app
import android.app.Activity
import android.app.AlertDialog
import android.app.AppOpsManager
import android.content.Context
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 com.getcapacitor.JSArray
@@ -177,6 +180,20 @@ object UnifiedPushManager {
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)
}
@@ -246,6 +263,7 @@ object UnifiedPushManager {
status.put("registered", !getEndpoint(context).isNullOrBlank())
status.put("distributor", runCatching { UnifiedPush.getSavedDistributor(context) }.getOrDefault(""))
status.put("lastFailure", getLastFailure(context) ?: "")
status.put("autoStartBlocked", isAutoStartRestricted(context))
val distributors = JSArray()
runCatching { UnifiedPush.getDistributors(context) }
.getOrDefault(emptyList())
@@ -254,6 +272,26 @@ object UnifiedPushManager {
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) {
Log.i(TAG, "onNewEndpoint instance=$instance endpoint=$endpoint")
clearLastFailure(context)

View File

@@ -2,5 +2,14 @@
include ':capacitor-android'
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'
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

Submodule cinny updated: 0fb3da20b9...9887903f49

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -45,22 +45,42 @@ type MobileSwipeGestureHostProps = {
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 => {
if (!(target instanceof Element)) return true;
return Boolean(
target.closest(
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-back="true"]'
)
);
return Boolean(target.closest(IGNORE_BACK_SELECTOR)) || isInsideHorizontalScroller(target);
};
const shouldIgnoreReplyTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return true;
return Boolean(
target.closest(
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-reply="true"]'
)
);
return Boolean(target.closest(IGNORE_REPLY_SELECTOR)) || isInsideHorizontalScroller(target);
};
const findMessageElement = (target: EventTarget | null): HTMLElement | null => {

View File

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

View File

@@ -172,6 +172,7 @@ type PushStatus = {
endpoint: string;
distributors: string[];
lastFailure: string;
autoStartBlocked: boolean;
};
/** Android-only section showing UnifiedPush registration status and controls. */
@@ -196,6 +197,7 @@ function AndroidPushNotifications() {
endpoint: s.endpoint || '',
distributors,
lastFailure: s.lastFailure || '',
autoStartBlocked: Boolean(s.autoStartBlocked),
}
: undefined
);
@@ -246,6 +248,13 @@ function AndroidPushNotifications() {
</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">

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

View File

@@ -121,7 +121,8 @@ function notificationBodyFromEvent(mEvent: MatrixEvent): string | undefined {
const rawBody = typeof content.body === 'string' ? content.body : undefined;
switch (content.msgtype) {
case 'm.image':
return rawBody ? `📷 ${rawBody}` : '📷 Photo';
// Matrix body is usually a filename — skip it in the shade.
return '📷 Photo';
case 'm.video':
return rawBody ? `🎥 ${rawBody}` : '🎥 Video';
case 'm.audio':
@@ -129,7 +130,7 @@ function notificationBodyFromEvent(mEvent: MatrixEvent): string | undefined {
case 'm.file':
return rawBody ? `📎 ${rawBody}` : '📎 File';
case 'm.sticker':
return rawBody ? `🖼️ ${rawBody}` : '🖼️ Sticker';
return '🖼️ Sticker';
default:
return rawBody;
}

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

View File

@@ -9,6 +9,8 @@ type UnifiedPushStatus = {
distributor: string;
distributors: string[] | string;
lastFailure?: string;
/** OEM App Boot / AUTO_START is blocking distributor broadcasts (e.g. TCL). */
autoStartBlocked?: boolean;
};
type ClearRoomNotificationsResult = {

View File

@@ -1,7 +1,5 @@
import FileSaver from 'file-saver';
import { Capacitor, registerPlugin } from '@capacitor/core';
import { downloadMedia } from './matrix';
import { getCurrentAccessToken } from './auth';
import { registerMediaSaver } from './saveMedia';
interface AndroidShareHandlerSavePlugin {
saveFile(options: {
@@ -9,19 +7,10 @@ interface AndroidShareHandlerSavePlugin {
mimeType?: string;
base64: 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 isAndroidNative = (): boolean =>
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
/** Convert a Blob to raw base64 (no data: prefix). */
const blobToBase64 = (blob: Blob): Promise<string> =>
new Promise((resolve, reject) => {
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
* ShareHandler plugin (FileSaver / `<a download>` does not work in WebView).
* Elsewhere falls back to FileSaver.
* Wire Capacitor Android MediaStore saves into the core saveMedia helpers.
* Call once at app startup from the mobile overlay entry.
*/
export const saveMediaBlob = async (blob: Blob, filename: string): Promise<void> => {
if (isAndroidNative()) {
export function registerAndroidMediaSaver(): void {
if (!(Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android')) {
return;
}
registerMediaSaver(async (blob, filename) => {
const base64 = await blobToBase64(blob);
const mimeType = blob.type || guessMimeFromName(filename) || 'application/octet-stream';
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);
};
});
}

View File

@@ -19,6 +19,19 @@ export const openExternalUrl = async (url: string): Promise<void> => {
}
}
// 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
if (isTauri() && !isElectron()) {
try {

View File

@@ -16,6 +16,8 @@ import { trimTrailingSlash } from './app/utils/common';
import App from './app/pages/App';
import { applySafeAreaInsets, isTauri } from './app/utils/tauri';
import { enableViewTransitionsForNavigation } from './app/utils/viewTransitions';
import { initAndroidDeepLinks } from './app/utils/androidDeepLink';
import { registerAndroidMediaSaver } from './app/utils/registerAndroidMediaSaver';
// import i18n (needs to be bundled ;))
import './app/i18n';
@@ -28,6 +30,12 @@ applySafeAreaInsets();
// Enable View Transitions API for smooth navigation
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
if ('serviceWorker' in navigator) {
const swUrl =

24
package-lock.json generated
View File

@@ -1,15 +1,17 @@
{
"name": "paarrot",
"version": "4.11.121",
"version": "4.11.150",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paarrot",
"version": "4.11.121",
"version": "4.11.150",
"license": "AGPL-3.0-only",
"dependencies": {
"@capacitor/android": "8.3.0",
"@capacitor/app": "8.0.1",
"@capacitor/browser": "8.0.1",
"@capacitor/cli": "8.3.0",
"@capacitor/core": "8.3.0",
"@capacitor/local-notifications": "8.0.2",
@@ -33,6 +35,24 @@
"@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": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.3.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "paarrot",
"version": "4.11.143",
"version": "4.11.152",
"description": "Paarrot - A Matrix client based on Cinny",
"engines": {
"node": ">=18.0.0"
@@ -23,6 +23,8 @@
"license": "AGPL-3.0-only",
"dependencies": {
"@capacitor/android": "8.3.0",
"@capacitor/app": "8.0.1",
"@capacitor/browser": "8.0.1",
"@capacitor/cli": "8.3.0",
"@capacitor/core": "8.3.0",
"@capacitor/local-notifications": "8.0.2",