diff --git a/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt b/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt index a22bf71..9185cdd 100644 --- a/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt +++ b/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt @@ -21,6 +21,8 @@ import android.graphics.PorterDuffXfermode import android.graphics.Rect import androidx.core.app.NotificationCompat import androidx.core.app.Person +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -118,13 +120,20 @@ class MatrixSyncService : Service() { // "foreground" flag is stale; JS path is suppressed while backgrounded. val fromPush = triggerReason.startsWith("unifiedpush") val suppressBecauseUi = appInForeground && !fromPush - if (!isFirstSync && !suppressBecauseUi) { - processRoomEvents(json, userId) + if (!isFirstSync) { + // 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 { - Log.d( - TAG, - "Skipping notifications (firstSync=$isFirstSync, foreground=$appInForeground, reason=$triggerReason)", - ) + Log.d(TAG, "Skipping notifications (firstSync=true, reason=$triggerReason)") } } } @@ -189,6 +198,24 @@ class MatrixSyncService : Service() { 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) { val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE) val homeserver = prefs.getString(EXTRA_HOMESERVER, null)?.trimEnd('/') ?: return @@ -766,11 +793,48 @@ class MatrixSyncService : Service() { fun sanitizeNotificationText(text: String): String = 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 * 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. */ fun postMessageNotification( @@ -821,7 +885,8 @@ class MatrixSyncService : Service() { val avatar = largeIcon?.let { toCircularBitmap(it) } val personBuilder = Person.Builder() .setName(resolvedSender) - .setKey(resolvedSender) + .setKey("$roomId:$resolvedSender") + .setImportant(true) if (avatar != null) { personBuilder.setIcon(IconCompat.createWithBitmap(avatar)) } @@ -839,6 +904,14 @@ class MatrixSyncService : Service() { "$resolvedSender: $resolvedMessage" } + val shortcut = publishConversationShortcut( + context, + roomId, + collapsedTitle, + senderPerson, + launchIntent, + ) + val builder = NotificationCompat.Builder(context, channelId) .setSmallIcon(R.drawable.ic_stat_paarrot) .setContentTitle(collapsedTitle) @@ -850,7 +923,8 @@ class MatrixSyncService : Service() { .setGroup(groupId) .setOnlyAlertOnce(true) .setSubText(groupName) - .setShortcutId(roomId) + .setShortcutId(shortcut.id) + .setShortcutInfo(shortcut) // Prevent system "Open link in Chrome/Firefox" contextual actions on URL bodies. .setAllowSystemGeneratedContextualActions(false) @@ -860,7 +934,7 @@ class MatrixSyncService : Service() { 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 (inlineImage != null) { @@ -873,19 +947,14 @@ class MatrixSyncService : Service() { .setSummaryText(collapsedText) ) } else { - val messaging = NotificationCompat.MessagingStyle(senderPerson) - .addMessage( - NotificationCompat.MessagingStyle.Message( - resolvedMessage, - System.currentTimeMillis(), - senderPerson, - ) - ) - if (!resolvedConversation.isNullOrBlank() && !isDm) { - messaging.conversationTitle = resolvedConversation - messaging.isGroupConversation = true - } - builder.setStyle(messaging) + // BigTextStyle keeps setLargeIcon visible when collapsed on most OEMs. + // MessagingStyle alone often hides the avatar until the user expands. + builder.setStyle( + NotificationCompat.BigTextStyle() + .bigText(collapsedText) + .setBigContentTitle(collapsedTitle) + .setSummaryText(groupName) + ) } nm.notify(roomNotifId, builder.build()) diff --git a/android/app/src/main/java/com/paarrot/app/ShareHandlerPlugin.kt b/android/app/src/main/java/com/paarrot/app/ShareHandlerPlugin.kt index 8eb7f57..9a8a6b0 100644 --- a/android/app/src/main/java/com/paarrot/app/ShareHandlerPlugin.kt +++ b/android/app/src/main/java/com/paarrot/app/ShareHandlerPlugin.kt @@ -1,10 +1,14 @@ package com.paarrot.app -import android.app.Activity +import android.content.ContentValues import android.content.Context import android.content.Intent import android.net.Uri 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.JSObject import com.getcapacitor.Plugin @@ -213,4 +217,168 @@ class ShareHandlerPlugin : Plugin() { ShareIntentStore.clearIntent(ShareIntentStore.pendingShare?.receivedAt) call.resolve() } + + /** + * Save a base64-encoded file to the device gallery / Downloads. + * Capacitor WebView cannot use FileSaver / ``, 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" + } } diff --git a/overlay/src/app/components/Pdf-viewer/PdfViewer.tsx b/overlay/src/app/components/Pdf-viewer/PdfViewer.tsx new file mode 100644 index 0000000..709441a --- /dev/null +++ b/overlay/src/app/components/Pdf-viewer/PdfViewer.tsx @@ -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(null); + const scrollRef = useRef(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(); + + 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 = (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 = (evt) => { + setJumpAnchor(evt.currentTarget.getBoundingClientRect()); + }; + + return ( + +
+ + + + + + {name} + + + + + + + setZoom(zoom === 1 ? 2 : 1)}> + {Math.round(zoom * 100)}% + + 1 ? 'Success' : 'SurfaceVariant'} + outlined={zoom > 1} + size="300" + radii="Pill" + onClick={zoomIn} + aria-label="Zoom In" + > + + + } + > + Download + + +
+ + {isLoading && } + {isError && ( + <> + Failed to load PDF + + + )} + {docState.status === AsyncStatus.Success && ( + + +
+ + + )} + + {docState.status === AsyncStatus.Success && ( +
+ } + onClick={handlePrevPage} + aria-disabled={pageNo <= 1} + > + Previous + + + setJumpAnchor(undefined), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + + + + + + + } + > + + {`${pageNo}/${docState.data.numPages}`} + + + + } + onClick={handleNextPage} + aria-disabled={pageNo >= docState.data.numPages} + > + Next + +
+ )} + + ); + } +); diff --git a/overlay/src/app/components/image-viewer/ImageViewer.tsx b/overlay/src/app/components/image-viewer/ImageViewer.tsx new file mode 100644 index 0000000..d8e6c7d --- /dev/null +++ b/overlay/src/app/components/image-viewer/ImageViewer.tsx @@ -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 ( + +
+ + + + + + {alt} + + + + + + + setZoom(zoom === 1 ? 2 : 1)}> + {Math.round(zoom * 100)}% + + 1 ? 'Success' : 'SurfaceVariant'} + outlined={zoom > 1} + size="300" + radii="Pill" + onClick={zoomIn} + aria-label="Zoom In" + > + + + } + > + Download + + +
+ + {alt} + +
+ ); + } +); diff --git a/overlay/src/app/components/message/FileHeader.tsx b/overlay/src/app/components/message/FileHeader.tsx new file mode 100644 index 0000000..d86d5ff --- /dev/null +++ b/overlay/src/app/components/message/FileHeader.tsx @@ -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 ( + + {downloading ? ( + + ) : ( + + )} + + ); +} + +export type FileHeaderProps = { + body: string; + mimeType: string; + after?: ReactNode; +}; +export const FileHeader = as<'div', FileHeaderProps>(({ body, mimeType, after, ...props }, ref) => ( + + + + + {mimeTypeToExt(mimeType)} + + + + + + {body} + + + {after} + +)); diff --git a/overlay/src/app/components/message/content/FileContent.tsx b/overlay/src/app/components/message/content/FileContent.tsx new file mode 100644 index 0000000..82f9706 --- /dev/null +++ b/overlay/src/app/components/message/content/FileContent.tsx @@ -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) => ( + + Failed to load file! + + } + position="Top" + align="Center" + > + {(triggerRef) => ( + + )} + +); + +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 && ( + }> + + setTextViewer(false), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + 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), + })} + + + + + )} + {textState.status === AsyncStatus.Error ? ( + renderErrorButton(loadText, 'Open File') + ) : ( + + )} + + ); +} + +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 && ( + }> + + setPdfViewer(false), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + evt.stopPropagation()} + > + {renderViewer({ + name: body, + src: pdfState.data, + requestClose: () => setPdfViewer(false), + })} + + + + + )} + {pdfState.status === AsyncStatus.Error ? ( + renderErrorButton(loadPdf, 'Open PDF') + ) : ( + + )} + + ); +} + +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)})`) + ) : ( + + ); +} + +type FileContentProps = { + body: string; + mimeType: string; + renderAsTextFile: () => ReactNode; + renderAsPdfFile: () => ReactNode; +}; +export const FileContent = as<'div', FileContentProps>( + ({ body, mimeType, renderAsTextFile, renderAsPdfFile, children, ...props }, ref) => ( + + {(READABLE_TEXT_MIME_TYPES.includes(mimeType) || + READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)]) && + renderAsTextFile()} + {mimeType === 'application/pdf' && renderAsPdfFile()} + {children} + + ) +); diff --git a/overlay/src/app/components/video-viewer/VideoViewer.tsx b/overlay/src/app/components/video-viewer/VideoViewer.tsx new file mode 100644 index 0000000..99a3176 --- /dev/null +++ b/overlay/src/app/components/video-viewer/VideoViewer.tsx @@ -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 ( + +
+ + + + + + {alt} + + + + } + > + Download + + +
+ + +
+ ); + } +); diff --git a/overlay/src/app/pages/client/ClientNonUIFeatures.tsx b/overlay/src/app/pages/client/ClientNonUIFeatures.tsx index 2784323..dc6e5f8 100644 --- a/overlay/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/overlay/src/app/pages/client/ClientNonUIFeatures.tsx @@ -425,16 +425,30 @@ function MessageNotifications() { const roomToUnread = useAtomValue(roomToUnreadAtom); const previousUnreadRoomsRef = useRef>(new Set()); + const previousUnreadTotalsRef = useRef>(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(() => { const currentRooms = new Set(roomToUnread.keys()); + const nextTotals = new Map(); + + 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) { if (!currentRooms.has(roomId)) { void clearNotificationsForRoom(roomId); } } + previousUnreadRoomsRef.current = currentRooms; + previousUnreadTotalsRef.current = nextTotals; }, [roomToUnread]); const notify = useCallback( diff --git a/overlay/src/app/utils/androidShare.ts b/overlay/src/app/utils/androidShare.ts index 445cc96..53c94cb 100644 --- a/overlay/src/app/utils/androidShare.ts +++ b/overlay/src/app/utils/androidShare.ts @@ -21,6 +21,18 @@ interface AndroidShareHandlerPlugin { getPendingShare(): Promise<{ share: AndroidSharePayload | null }>; /** Clears the last pending share payload after it has been consumed. */ clearPendingShare(): Promise; + /** 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( eventName: 'shareReceived', listenerFunc: (payload: AndroidSharePayload) => void diff --git a/overlay/src/app/utils/saveMedia.ts b/overlay/src/app/utils/saveMedia.ts new file mode 100644 index 0000000..98e1474 --- /dev/null +++ b/overlay/src/app/utils/saveMedia.ts @@ -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('AndroidShareHandler'); + +const isAndroidNative = (): boolean => + Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android'; + +/** Convert a Blob to raw base64 (no data: prefix). */ +const blobToBase64 = (blob: Blob): Promise => + 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 = { + 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 / `
` does not work in WebView). + * Elsewhere falls back to FileSaver. + */ +export const saveMediaBlob = async (blob: Blob, filename: string): Promise => { + 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 => { + 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); +};