Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19a46c4d95 | ||
| 8cf31b929d |
@@ -21,6 +21,8 @@ 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.pm.ShortcutInfoCompat
|
||||||
|
import androidx.core.content.pm.ShortcutManagerCompat
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -118,13 +120,20 @@ class MatrixSyncService : Service() {
|
|||||||
// "foreground" flag is stale; JS path is suppressed while backgrounded.
|
// "foreground" flag is stale; JS path is suppressed while backgrounded.
|
||||||
val fromPush = triggerReason.startsWith("unifiedpush")
|
val fromPush = triggerReason.startsWith("unifiedpush")
|
||||||
val suppressBecauseUi = appInForeground && !fromPush
|
val suppressBecauseUi = appInForeground && !fromPush
|
||||||
if (!isFirstSync && !suppressBecauseUi) {
|
if (!isFirstSync) {
|
||||||
processRoomEvents(json, userId)
|
// Always dismiss trays when another device (or this one) marked rooms read,
|
||||||
|
// even while the UI is foregrounded and posting is suppressed.
|
||||||
|
dismissClearedRoomNotifications(json)
|
||||||
|
if (!suppressBecauseUi) {
|
||||||
|
processRoomEvents(json, userId)
|
||||||
|
} else {
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"Skipping notification posts (foreground=$appInForeground, reason=$triggerReason)",
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Log.d(
|
Log.d(TAG, "Skipping notifications (firstSync=true, reason=$triggerReason)")
|
||||||
TAG,
|
|
||||||
"Skipping notifications (firstSync=$isFirstSync, foreground=$appInForeground, reason=$triggerReason)",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,6 +198,24 @@ class MatrixSyncService : Service() {
|
|||||||
val pushRules: JSONObject?,
|
val pushRules: JSONObject?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel tray notifications for rooms the homeserver now reports as fully read.
|
||||||
|
* Covers “marked as read on another device” while this phone is backgrounded.
|
||||||
|
* Only acts when [unread_notifications] is present in this sync batch (count changed).
|
||||||
|
*/
|
||||||
|
private fun dismissClearedRoomNotifications(sync: JSONObject) {
|
||||||
|
val joinedRooms = sync.optJSONObject("rooms")?.optJSONObject("join") ?: return
|
||||||
|
for (roomId in joinedRooms.keys().asSequence()) {
|
||||||
|
val roomData = joinedRooms.optJSONObject(roomId) ?: continue
|
||||||
|
if (!roomData.has("unread_notifications")) continue
|
||||||
|
val unread = roomData.optJSONObject("unread_notifications")
|
||||||
|
val notificationCount = unread?.optInt("notification_count", 0) ?: 0
|
||||||
|
if (notificationCount <= 0) {
|
||||||
|
clearRoomNotifications(applicationContext, roomId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun processRoomEvents(sync: JSONObject, myUserId: String) {
|
private fun processRoomEvents(sync: JSONObject, myUserId: String) {
|
||||||
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
|
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
|
||||||
val homeserver = prefs.getString(EXTRA_HOMESERVER, null)?.trimEnd('/') ?: return
|
val homeserver = prefs.getString(EXTRA_HOMESERVER, null)?.trimEnd('/') ?: return
|
||||||
@@ -766,11 +793,48 @@ class MatrixSyncService : Service() {
|
|||||||
fun sanitizeNotificationText(text: String): String =
|
fun sanitizeNotificationText(text: String): String =
|
||||||
text.replace(Regex("""https?://\S+""", RegexOption.IGNORE_CASE), "🔗 link")
|
text.replace(Regex("""https?://\S+""", RegexOption.IGNORE_CASE), "🔗 link")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish a long-lived conversation shortcut so Android 11+ shows the sender
|
||||||
|
* avatar in the collapsed shade (MessagingStyle alone only shows it expanded).
|
||||||
|
*/
|
||||||
|
private fun publishConversationShortcut(
|
||||||
|
context: Context,
|
||||||
|
roomId: String,
|
||||||
|
label: String,
|
||||||
|
person: Person,
|
||||||
|
launchIntent: Intent,
|
||||||
|
): ShortcutInfoCompat {
|
||||||
|
val shortcutIntent = Intent(launchIntent).apply {
|
||||||
|
// Shortcuts require an explicit action.
|
||||||
|
if (action.isNullOrBlank()) {
|
||||||
|
action = NotificationNavStore.ACTION_OPEN_NOTIFICATION
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val icon = person.icon
|
||||||
|
?: IconCompat.createWithResource(context, R.drawable.ic_stat_paarrot)
|
||||||
|
val shortcut = ShortcutInfoCompat.Builder(context, "room:$roomId")
|
||||||
|
.setShortLabel(label.take(25).ifBlank { "Chat" })
|
||||||
|
.setLongLabel(label.ifBlank { "Chat" })
|
||||||
|
.setIcon(icon)
|
||||||
|
.setIntent(shortcutIntent)
|
||||||
|
.setPerson(person)
|
||||||
|
.setLongLived(true)
|
||||||
|
.setCategories(setOf("android.shortcut.conversation"))
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to publish conversation shortcut: ${e.message}")
|
||||||
|
}
|
||||||
|
return shortcut
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Posts a room notification (+ space/DM group summary) for both background sync
|
* Posts a room notification (+ space/DM group summary) for both background sync
|
||||||
* and JS-driven Capacitor notifications.
|
* and JS-driven Capacitor notifications.
|
||||||
*
|
*
|
||||||
* Avatar goes in the circular Person / largeIcon slot on the left.
|
* Avatar goes in the circular largeIcon / conversation-shortcut slot (visible when
|
||||||
|
* collapsed). The small status-bar icon stays the Paarrot mark (Android requirement).
|
||||||
* Optional image attachments use BigPictureStyle while keeping that circular avatar.
|
* Optional image attachments use BigPictureStyle while keeping that circular avatar.
|
||||||
*/
|
*/
|
||||||
fun postMessageNotification(
|
fun postMessageNotification(
|
||||||
@@ -821,7 +885,8 @@ class MatrixSyncService : Service() {
|
|||||||
val avatar = largeIcon?.let { toCircularBitmap(it) }
|
val avatar = largeIcon?.let { toCircularBitmap(it) }
|
||||||
val personBuilder = Person.Builder()
|
val personBuilder = Person.Builder()
|
||||||
.setName(resolvedSender)
|
.setName(resolvedSender)
|
||||||
.setKey(resolvedSender)
|
.setKey("$roomId:$resolvedSender")
|
||||||
|
.setImportant(true)
|
||||||
if (avatar != null) {
|
if (avatar != null) {
|
||||||
personBuilder.setIcon(IconCompat.createWithBitmap(avatar))
|
personBuilder.setIcon(IconCompat.createWithBitmap(avatar))
|
||||||
}
|
}
|
||||||
@@ -839,6 +904,14 @@ class MatrixSyncService : Service() {
|
|||||||
"$resolvedSender: $resolvedMessage"
|
"$resolvedSender: $resolvedMessage"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val shortcut = publishConversationShortcut(
|
||||||
|
context,
|
||||||
|
roomId,
|
||||||
|
collapsedTitle,
|
||||||
|
senderPerson,
|
||||||
|
launchIntent,
|
||||||
|
)
|
||||||
|
|
||||||
val builder = NotificationCompat.Builder(context, channelId)
|
val builder = NotificationCompat.Builder(context, channelId)
|
||||||
.setSmallIcon(R.drawable.ic_stat_paarrot)
|
.setSmallIcon(R.drawable.ic_stat_paarrot)
|
||||||
.setContentTitle(collapsedTitle)
|
.setContentTitle(collapsedTitle)
|
||||||
@@ -850,7 +923,8 @@ class MatrixSyncService : Service() {
|
|||||||
.setGroup(groupId)
|
.setGroup(groupId)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
.setSubText(groupName)
|
.setSubText(groupName)
|
||||||
.setShortcutId(roomId)
|
.setShortcutId(shortcut.id)
|
||||||
|
.setShortcutInfo(shortcut)
|
||||||
// Prevent system "Open link in Chrome/Firefox" contextual actions on URL bodies.
|
// Prevent system "Open link in Chrome/Firefox" contextual actions on URL bodies.
|
||||||
.setAllowSystemGeneratedContextualActions(false)
|
.setAllowSystemGeneratedContextualActions(false)
|
||||||
|
|
||||||
@@ -860,7 +934,7 @@ class MatrixSyncService : Service() {
|
|||||||
builder.extras.putString(NotificationNavStore.EXTRA_NAV_PATH, path)
|
builder.extras.putString(NotificationNavStore.EXTRA_NAV_PATH, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Circular avatar on the left (collapsed + MessagingStyle Person).
|
// 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)
|
||||||
|
|
||||||
if (inlineImage != null) {
|
if (inlineImage != null) {
|
||||||
@@ -873,19 +947,14 @@ class MatrixSyncService : Service() {
|
|||||||
.setSummaryText(collapsedText)
|
.setSummaryText(collapsedText)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val messaging = NotificationCompat.MessagingStyle(senderPerson)
|
// BigTextStyle keeps setLargeIcon visible when collapsed on most OEMs.
|
||||||
.addMessage(
|
// MessagingStyle alone often hides the avatar until the user expands.
|
||||||
NotificationCompat.MessagingStyle.Message(
|
builder.setStyle(
|
||||||
resolvedMessage,
|
NotificationCompat.BigTextStyle()
|
||||||
System.currentTimeMillis(),
|
.bigText(collapsedText)
|
||||||
senderPerson,
|
.setBigContentTitle(collapsedTitle)
|
||||||
)
|
.setSummaryText(groupName)
|
||||||
)
|
)
|
||||||
if (!resolvedConversation.isNullOrBlank() && !isDm) {
|
|
||||||
messaging.conversationTitle = resolvedConversation
|
|
||||||
messaging.isGroupConversation = true
|
|
||||||
}
|
|
||||||
builder.setStyle(messaging)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nm.notify(roomNotifId, builder.build())
|
nm.notify(roomNotifId, builder.build())
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package com.paarrot.app
|
package com.paarrot.app
|
||||||
|
|
||||||
import android.app.Activity
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.util.Base64
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
import com.getcapacitor.JSArray
|
import com.getcapacitor.JSArray
|
||||||
import com.getcapacitor.JSObject
|
import com.getcapacitor.JSObject
|
||||||
import com.getcapacitor.Plugin
|
import com.getcapacitor.Plugin
|
||||||
@@ -213,4 +217,168 @@ class ShareHandlerPlugin : Plugin() {
|
|||||||
ShareIntentStore.clearIntent(ShareIntentStore.pendingShare?.receivedAt)
|
ShareIntentStore.clearIntent(ShareIntentStore.pendingShare?.receivedAt)
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a base64-encoded file to the device gallery / Downloads.
|
||||||
|
* Capacitor WebView cannot use FileSaver / `<a download>`, so downloads go through MediaStore.
|
||||||
|
* Images → Pictures/Paarrot, videos → Movies/Paarrot, other → Download/Paarrot (API 29+).
|
||||||
|
* On older APIs or MediaStore failure, falls back to the system share sheet.
|
||||||
|
*/
|
||||||
|
@PluginMethod
|
||||||
|
fun saveFile(call: PluginCall) {
|
||||||
|
val filename = call.getString("filename") ?: run {
|
||||||
|
call.reject("filename required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val mimeType = call.getString("mimeType")
|
||||||
|
?: guessMime(filename)
|
||||||
|
?: "application/octet-stream"
|
||||||
|
val base64 = call.getString("base64") ?: run {
|
||||||
|
call.reject("base64 required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val cleaned = base64.substringAfter("base64,", base64).trim()
|
||||||
|
val bytes = Base64.decode(cleaned, Base64.DEFAULT)
|
||||||
|
if (bytes.isEmpty()) {
|
||||||
|
call.reject("empty file data")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val uri = saveBytes(bytes, filename, mimeType)
|
||||||
|
if (uri != null) {
|
||||||
|
android.widget.Toast.makeText(
|
||||||
|
context,
|
||||||
|
"Saved $filename",
|
||||||
|
android.widget.Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
call.resolve(
|
||||||
|
JSObject()
|
||||||
|
.put("saved", true)
|
||||||
|
.put("uri", uri.toString())
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
shareBytes(bytes, filename, mimeType)
|
||||||
|
call.resolve(JSObject().put("saved", false).put("shared", true))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e(TAG, "saveFile failed", e)
|
||||||
|
call.reject("Failed to save file: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the system share sheet for a base64 file (Save to Files, Drive, etc.).
|
||||||
|
*/
|
||||||
|
@PluginMethod
|
||||||
|
fun shareFile(call: PluginCall) {
|
||||||
|
val filename = call.getString("filename") ?: run {
|
||||||
|
call.reject("filename required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val mimeType = call.getString("mimeType")
|
||||||
|
?: guessMime(filename)
|
||||||
|
?: "application/octet-stream"
|
||||||
|
val base64 = call.getString("base64") ?: run {
|
||||||
|
call.reject("base64 required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val cleaned = base64.substringAfter("base64,", base64).trim()
|
||||||
|
val bytes = Base64.decode(cleaned, Base64.DEFAULT)
|
||||||
|
shareBytes(bytes, filename, mimeType)
|
||||||
|
call.resolve(JSObject().put("shared", true))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e(TAG, "shareFile failed", e)
|
||||||
|
call.reject("Failed to share file: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveBytes(bytes: ByteArray, filename: String, mimeType: String): Uri? {
|
||||||
|
return when {
|
||||||
|
mimeType.startsWith("image/") ->
|
||||||
|
insertMedia(bytes, filename, mimeType, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "Pictures/Paarrot")
|
||||||
|
mimeType.startsWith("video/") ->
|
||||||
|
insertMedia(bytes, filename, mimeType, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, "Movies/Paarrot")
|
||||||
|
mimeType.startsWith("audio/") ->
|
||||||
|
insertMedia(bytes, filename, mimeType, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, "Music/Paarrot")
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ->
|
||||||
|
insertMedia(bytes, filename, mimeType, MediaStore.Downloads.EXTERNAL_CONTENT_URI, "Download/Paarrot")
|
||||||
|
else -> null // share-sheet fallback (avoids WRITE_EXTERNAL_STORAGE on API < 29)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun insertMedia(
|
||||||
|
bytes: ByteArray,
|
||||||
|
filename: String,
|
||||||
|
mimeType: String,
|
||||||
|
collection: Uri,
|
||||||
|
relativePath: String
|
||||||
|
): Uri? {
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
|
||||||
|
put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
|
||||||
|
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val uri = resolver.insert(collection, values) ?: return null
|
||||||
|
try {
|
||||||
|
resolver.openOutputStream(uri)?.use { it.write(bytes) }
|
||||||
|
?: run {
|
||||||
|
resolver.delete(uri, null, null)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
resolver.delete(uri, null, null)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
values.clear()
|
||||||
|
values.put(MediaStore.MediaColumns.IS_PENDING, 0)
|
||||||
|
resolver.update(uri, values, null, null)
|
||||||
|
}
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shareBytes(bytes: ByteArray, filename: String, mimeType: String) {
|
||||||
|
val cacheDir = File(context.cacheDir, "shares").apply { mkdirs() }
|
||||||
|
val safeName = filename.replace(Regex("[^a-zA-Z0-9._\\-]"), "_").take(128).ifEmpty { "file" }
|
||||||
|
val file = File(cacheDir, safeName)
|
||||||
|
FileOutputStream(file).use { it.write(bytes) }
|
||||||
|
|
||||||
|
val uri = FileProvider.getUriForFile(
|
||||||
|
context,
|
||||||
|
"${context.packageName}.fileprovider",
|
||||||
|
file
|
||||||
|
)
|
||||||
|
|
||||||
|
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = mimeType
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
putExtra(Intent.EXTRA_SUBJECT, filename)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
|
||||||
|
val chooser = Intent.createChooser(intent, "Save or share").apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
context.startActivity(chooser)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun guessMime(filename: String): String? {
|
||||||
|
val ext = filename.substringAfterLast('.', "").lowercase()
|
||||||
|
if (ext.isEmpty()) return null
|
||||||
|
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ShareHandlerPlugin"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
251
overlay/src/app/components/Pdf-viewer/PdfViewer.tsx
Normal file
251
overlay/src/app/components/Pdf-viewer/PdfViewer.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
107
overlay/src/app/components/image-viewer/ImageViewer.tsx
Normal file
107
overlay/src/app/components/image-viewer/ImageViewer.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
84
overlay/src/app/components/message/FileHeader.tsx
Normal file
84
overlay/src/app/components/message/FileHeader.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
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>
|
||||||
|
));
|
||||||
303
overlay/src/app/components/message/content/FileContent.tsx
Normal file
303
overlay/src/app/components/message/content/FileContent.tsx
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
);
|
||||||
74
overlay/src/app/components/video-viewer/VideoViewer.tsx
Normal file
74
overlay/src/app/components/video-viewer/VideoViewer.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -425,16 +425,30 @@ function MessageNotifications() {
|
|||||||
|
|
||||||
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||||
const previousUnreadRoomsRef = useRef<Set<string>>(new Set());
|
const previousUnreadRoomsRef = useRef<Set<string>>(new Set());
|
||||||
|
const previousUnreadTotalsRef = useRef<Map<string, number>>(new Map());
|
||||||
|
|
||||||
// Dismiss OS notifications when a room's unread is cleared (local read or receipt).
|
// Dismiss OS notifications when a room's unread is cleared
|
||||||
|
// (local mark-as-read, or our receipt syncing from another device).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentRooms = new Set(roomToUnread.keys());
|
const currentRooms = new Set(roomToUnread.keys());
|
||||||
|
const nextTotals = new Map<string, number>();
|
||||||
|
|
||||||
|
roomToUnread.forEach((unread, roomId) => {
|
||||||
|
nextTotals.set(roomId, unread.total);
|
||||||
|
const prevTotal = previousUnreadTotalsRef.current.get(roomId);
|
||||||
|
if (prevTotal !== undefined && prevTotal > 0 && unread.total === 0) {
|
||||||
|
void clearNotificationsForRoom(roomId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
for (const roomId of previousUnreadRoomsRef.current) {
|
for (const roomId of previousUnreadRoomsRef.current) {
|
||||||
if (!currentRooms.has(roomId)) {
|
if (!currentRooms.has(roomId)) {
|
||||||
void clearNotificationsForRoom(roomId);
|
void clearNotificationsForRoom(roomId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
previousUnreadRoomsRef.current = currentRooms;
|
previousUnreadRoomsRef.current = currentRooms;
|
||||||
|
previousUnreadTotalsRef.current = nextTotals;
|
||||||
}, [roomToUnread]);
|
}, [roomToUnread]);
|
||||||
|
|
||||||
const notify = useCallback(
|
const notify = useCallback(
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ interface AndroidShareHandlerPlugin {
|
|||||||
getPendingShare(): Promise<{ share: AndroidSharePayload | null }>;
|
getPendingShare(): Promise<{ share: AndroidSharePayload | null }>;
|
||||||
/** Clears the last pending share payload after it has been consumed. */
|
/** Clears the last pending share payload after it has been consumed. */
|
||||||
clearPendingShare(): Promise<void>;
|
clearPendingShare(): Promise<void>;
|
||||||
|
/** Save a base64 file to gallery / Downloads (MediaStore), or share-sheet fallback. */
|
||||||
|
saveFile(options: {
|
||||||
|
filename: string;
|
||||||
|
mimeType?: string;
|
||||||
|
base64: string;
|
||||||
|
}): Promise<{ saved: boolean; shared?: boolean; uri?: string }>;
|
||||||
|
/** Open the system share sheet for a base64 file. */
|
||||||
|
shareFile(options: {
|
||||||
|
filename: string;
|
||||||
|
mimeType?: string;
|
||||||
|
base64: string;
|
||||||
|
}): Promise<{ shared: boolean }>;
|
||||||
addListener(
|
addListener(
|
||||||
eventName: 'shareReceived',
|
eventName: 'shareReceived',
|
||||||
listenerFunc: (payload: AndroidSharePayload) => void
|
listenerFunc: (payload: AndroidSharePayload) => void
|
||||||
|
|||||||
103
overlay/src/app/utils/saveMedia.ts
Normal file
103
overlay/src/app/utils/saveMedia.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import FileSaver from 'file-saver';
|
||||||
|
import { Capacitor, registerPlugin } from '@capacitor/core';
|
||||||
|
import { downloadMedia } from './matrix';
|
||||||
|
import { getCurrentAccessToken } from './auth';
|
||||||
|
|
||||||
|
interface AndroidShareHandlerSavePlugin {
|
||||||
|
saveFile(options: {
|
||||||
|
filename: string;
|
||||||
|
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();
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result;
|
||||||
|
if (typeof result !== 'string') {
|
||||||
|
reject(new Error('Failed to read blob as base64'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(result.includes(',') ? result.split(',')[1] : result);
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(reader.error ?? new Error('FileReader failed'));
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
|
||||||
|
const guessMimeFromName = (filename: string): string | undefined => {
|
||||||
|
const ext = filename.split('.').pop()?.toLowerCase();
|
||||||
|
if (!ext) return undefined;
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
png: 'image/png',
|
||||||
|
gif: 'image/gif',
|
||||||
|
webp: 'image/webp',
|
||||||
|
bmp: 'image/bmp',
|
||||||
|
svg: 'image/svg+xml',
|
||||||
|
mp4: 'video/mp4',
|
||||||
|
webm: 'video/webm',
|
||||||
|
mkv: 'video/x-matroska',
|
||||||
|
mov: 'video/quicktime',
|
||||||
|
mp3: 'audio/mpeg',
|
||||||
|
ogg: 'audio/ogg',
|
||||||
|
wav: 'audio/wav',
|
||||||
|
pdf: 'application/pdf',
|
||||||
|
txt: 'text/plain',
|
||||||
|
};
|
||||||
|
return map[ext];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export const saveMediaBlob = async (blob: Blob, filename: string): Promise<void> => {
|
||||||
|
if (isAndroidNative()) {
|
||||||
|
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);
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.137",
|
"version": "4.11.138",
|
||||||
"description": "Paarrot - A Matrix client based on Cinny",
|
"description": "Paarrot - A Matrix client based on Cinny",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user