Compare commits

..

9 Commits

Author SHA1 Message Date
GitHub Actions
8cacf555e5 chore: bump version to 4.11.126 [skip ci] 2026-07-11 12:16:49 +00:00
5a1579dd01 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m46s
Build / create-release (push) Successful in 14s
2026-07-11 22:16:40 +10:00
792c735ffe feat: improve touch handling in MobileSwipeGestureHost for better swipe detection 2026-07-11 22:16:26 +10:00
GitHub Actions
32af57d9f1 chore: bump version to 4.11.125 [skip ci] 2026-07-11 11:55:21 +00:00
197474a049 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m29s
Build / create-release (push) Successful in 15s
2026-07-11 21:55:12 +10:00
19252fdbb0 feat: enhance clipboard image handling and share functionality in Android app 2026-07-11 21:55:05 +10:00
GitHub Actions
b260f741d5 chore: bump version to 4.11.124 [skip ci] 2026-07-11 11:41:37 +00:00
7655759bbc Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m51s
Build / create-release (push) Successful in 14s
2026-07-11 21:41:27 +10:00
d30b98c987 chore: mark subproject as dirty 2026-07-11 21:41:11 +10:00
9 changed files with 335 additions and 32 deletions

View File

@@ -0,0 +1,32 @@
package com.paarrot.app
import android.net.Uri
import android.view.View
import androidx.core.view.ContentInfoCompat
import androidx.core.view.OnReceiveContentListener
/**
* Receives rich content from the IME (Gboard GIF), clipboard paste, and drag-drop,
* then forwards file URIs through [ShareIntentStore] so JS can attach them in-room.
*/
class ImageKeyboardContentReceiver : OnReceiveContentListener {
override fun onReceiveContent(view: View, payload: ContentInfoCompat): ContentInfoCompat? {
val split = payload.partition { item -> item.uri != null }
val uriContent = split.first
val remaining = split.second
if (uriContent != null) {
val uris = mutableListOf<Uri>()
val clip = uriContent.clip
for (i in 0 until clip.itemCount) {
clip.getItemAt(i).uri?.let { uris.add(it) }
}
if (uris.isNotEmpty()) {
ShareIntentStore.handleContentUris(view.context, uris)
}
}
return remaining
}
}

View File

@@ -0,0 +1,38 @@
package com.paarrot.app
import android.content.Context
import android.util.AttributeSet
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat
import com.getcapacitor.CapacitorWebView
/**
* Capacitor WebView that advertises image/video MIME types to the IME
* (Gboard GIF/sticker keyboard) and routes commit-content through
* [androidx.core.view.ViewCompat.setOnReceiveContentListener].
*/
class ImageKeyboardWebView(
context: Context,
attrs: AttributeSet?,
) : CapacitorWebView(context, attrs) {
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
val inputConnection = super.onCreateInputConnection(outAttrs) ?: return null
EditorInfoCompat.setContentMimeTypes(outAttrs, MIME_TYPES)
return InputConnectionCompat.createWrapper(this, inputConnection, outAttrs)
}
companion object {
@JvmField
val MIME_TYPES: Array<String> = arrayOf(
"image/*",
"image/png",
"image/gif",
"image/jpeg",
"image/webp",
"video/*",
)
}
}

View File

@@ -1,6 +1,8 @@
package com.paarrot.app; package com.paarrot.app;
import android.os.Bundle; import android.os.Bundle;
import android.webkit.WebView;
import androidx.core.view.ViewCompat;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity { public class MainActivity extends BridgeActivity {
@@ -10,5 +12,18 @@ public class MainActivity extends BridgeActivity {
registerPlugin(ShareHandlerPlugin.class); registerPlugin(ShareHandlerPlugin.class);
registerPlugin(PluginStoragePlugin.class); registerPlugin(PluginStoragePlugin.class);
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setupImageKeyboardSupport();
} }
}
/** Advertise image MIME types and receive Gboard / paste / drag-drop media. */
private void setupImageKeyboardSupport() {
if (bridge == null) return;
WebView webView = bridge.getWebView();
if (webView == null) return;
ViewCompat.setOnReceiveContentListener(
webView,
ImageKeyboardWebView.MIME_TYPES,
new ImageKeyboardContentReceiver()
);
}
}

View File

@@ -62,7 +62,6 @@ object ShareIntentStore {
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return
val receivedAt = System.currentTimeMillis()
val text = intent.getStringExtra(Intent.EXTRA_TEXT) val text = intent.getStringExtra(Intent.EXTRA_TEXT)
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT) val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
@@ -86,8 +85,29 @@ object ShareIntentStore {
uri?.let { uris.add(it) } uri?.let { uris.add(it) }
} }
deliverShare(context, uris, text, subject)
}
/**
* Deliver content URIs from the IME (Gboard GIF), clipboard paste, or drag-drop
* into the same share pipeline used by ACTION_SEND.
*/
fun handleContentUris(context: Context, uris: List<Uri>) {
if (uris.isEmpty()) return
deliverShare(context, uris, text = null, subject = null)
}
private fun deliverShare(
context: Context,
uris: List<Uri>,
text: String?,
subject: String?,
) {
val receivedAt = System.currentTimeMillis()
val copiedFiles = uris.mapNotNull { copySharedUri(context, it, receivedAt) } val copiedFiles = uris.mapNotNull { copySharedUri(context, it, receivedAt) }
if (copiedFiles.isEmpty() && text.isNullOrBlank() && subject.isNullOrBlank()) return
val payload = SharePayload( val payload = SharePayload(
text = text, text = text,
subject = subject, subject = subject,

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.getcapacitor.BridgeActivity">
<com.paarrot.app.ImageKeyboardWebView
android:id="@+id/webview"
android:layout_width="fill_parent"
android:layout_height="fill_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -220,6 +220,12 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
const getEndedTouch = (evt: TouchEvent, touchId: number) => const getEndedTouch = (evt: TouchEvent, touchId: number) =>
Array.from(evt.changedTouches).find((touch) => touch.identifier === touchId); Array.from(evt.changedTouches).find((touch) => touch.identifier === touchId);
const isInsideSwipeHost = (target: EventTarget | null): boolean => {
const root = rootRef.current;
if (!root || !(target instanceof Node)) return false;
return root.contains(target);
};
const handleTouchStart = (evt: TouchEvent) => { const handleTouchStart = (evt: TouchEvent) => {
if (gestureRef.current.phase !== 'idle') return; if (gestureRef.current.phase !== 'idle') return;
@@ -232,7 +238,12 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
const messageEl = findMessageElement(target); const messageEl = findMessageElement(target);
const messageId = messageEl?.getAttribute('data-message-id') ?? null; const messageId = messageEl?.getAttribute('data-message-id') ?? null;
const canStartBack = backEnabled && !shouldIgnoreBackTarget(target); // Only steal touches that start inside the detail/swipe host. Document-level
// listeners otherwise fight the always-visible sidebar (space icons sit in the
// former screen-left EDGE_WIDTH zone and lost clicks to preventDefault).
const insideHost = isInsideSwipeHost(target);
const canStartBack =
backEnabled && insideHost && !shouldIgnoreBackTarget(target);
const canStartReply = const canStartReply =
replyEnabled && replyEnabled &&
messageEl && messageEl &&
@@ -242,23 +253,11 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
if (!canStartBack && !canStartReply) return; if (!canStartBack && !canStartReply) return;
if (touch.clientX <= EDGE_WIDTH && canStartBack) { const contentLeft = rootRef.current?.getBoundingClientRect().left ?? 0;
evt.preventDefault(); const nearContentLeftEdge =
gestureRef.current = { touch.clientX >= contentLeft && touch.clientX <= contentLeft + EDGE_WIDTH;
phase: 'back',
touchId: touch.identifier,
startX: touch.clientX,
startY: touch.clientY,
messageEl: null,
transformEl: null,
messageId: null,
edgeBack: true,
offset: 0,
};
document.documentElement.classList.add('mobile-gesture-lock');
return;
}
// Prefer pending over immediate back+preventDefault so a tap still clicks.
gestureRef.current = { gestureRef.current = {
phase: 'pending', phase: 'pending',
touchId: touch.identifier, touchId: touch.identifier,
@@ -267,7 +266,7 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
messageEl: canStartReply ? messageEl : null, messageEl: canStartReply ? messageEl : null,
transformEl: canStartReply && messageEl ? findReplyTransformTarget(messageEl) : null, transformEl: canStartReply && messageEl ? findReplyTransformTarget(messageEl) : null,
messageId: canStartReply ? messageId : null, messageId: canStartReply ? messageId : null,
edgeBack: false, edgeBack: nearContentLeftEdge && canStartBack,
offset: 0, offset: 0,
}; };
}; };

View File

@@ -0,0 +1,111 @@
import { useCallback, ClipboardEventHandler } from 'react';
import { getDataTransferFiles } from '../utils/dom';
import { readClipboardImage, isTauri, isLinux, isCapacitorNative } from '../utils/tauri';
const filesFromClipboardItems = (data: DataTransfer | null): File[] => {
if (!data?.items) return [];
const files: File[] = [];
for (let i = 0; i < data.items.length; i += 1) {
const item = data.items[i];
if (item.kind !== 'file') continue;
const file = item.getAsFile();
if (file) files.push(file);
}
return files;
};
const filesFromHtmlImage = (data: DataTransfer | null): File[] => {
const html = data?.getData('text/html');
if (!html) return [];
const match = html.match(/<img[^>]+src=["'](data:image\/[a-zA-Z+]+;base64,[^"']+)["']/i);
if (!match?.[1]) return [];
try {
const dataUrl = match[1];
const [meta, base64] = dataUrl.split(',');
const mime = meta.match(/data:(image\/[a-zA-Z+]+);base64/i)?.[1] ?? 'image/png';
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
const ext = mime.split('/')[1]?.replace('+xml', '') || 'png';
return [new File([bytes], `clipboard-image.${ext}`, { type: mime })];
} catch {
return [];
}
};
const getClipboardFiles = (data: DataTransfer | null): File[] => {
if (!data) return [];
const fromFiles = getDataTransferFiles(data);
if (fromFiles && fromFiles.length > 0) return fromFiles;
const fromItems = filesFromClipboardItems(data);
if (fromItems.length > 0) return fromItems;
return filesFromHtmlImage(data);
};
/** Async Clipboard API fallback (when paste event has no file payload). */
const filesFromClipboardApi = async (): Promise<File[]> => {
if (!navigator.clipboard?.read) return [];
try {
const items = await navigator.clipboard.read();
const files: File[] = [];
for (const item of items) {
for (const type of item.types) {
if (!type.startsWith('image/')) continue;
const blob = await item.getType(type);
const ext = type.split('/')[1]?.replace('+xml', '') || 'png';
files.push(new File([blob], `clipboard-image.${ext}`, { type }));
}
}
return files;
} catch {
return [];
}
};
/**
* Paste handler for RoomInput. Supports clipboard images via files, DataTransferItem
* list, HTML data-URL fallbacks, and Clipboard API (common on mobile WebViews).
* On Capacitor Android, native OnReceiveContent also routes images into the share pipeline.
*/
export const useFilePasteHandler = (onPaste: (file: File[]) => void): ClipboardEventHandler =>
useCallback(
async (evt) => {
const files = getClipboardFiles(evt.clipboardData);
if (files.length > 0) {
evt.preventDefault();
evt.stopPropagation();
onPaste(files);
return;
}
// On Linux with Tauri, browser clipboard API doesn't work for images.
if (isTauri() && isLinux()) {
const clipboardImage = await readClipboardImage();
if (clipboardImage) {
evt.preventDefault();
evt.stopPropagation();
onPaste([clipboardImage]);
return;
}
}
// Mobile WebView / Capacitor: paste events often omit file payloads.
if (isCapacitorNative()) {
const apiFiles = await filesFromClipboardApi();
if (apiFiles.length > 0) {
evt.preventDefault();
evt.stopPropagation();
onPaste(apiFiles);
}
}
},
[onPaste]
);

View File

@@ -1,5 +1,4 @@
import { useAtomValue } from 'jotai'; import { useAtomValue, useStore } from 'jotai';
import { useAtom, useSetAtom } from 'jotai';
import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react'; import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
@@ -23,13 +22,12 @@ import {
guessPerfectParent, guessPerfectParent,
} from '../../utils/room'; } from '../../utils/room';
import { NotificationType, UnreadInfo } from '../../../types/matrix/room'; import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
import { getMxIdLocalPart, mxcUrlToHttp, getCanonicalAliasOrRoomId } from '../../utils/matrix'; import { getMxIdLocalPart, mxcUrlToHttp, getCanonicalAliasOrRoomId, encryptFile } from '../../utils/matrix';
import { mDirectAtom } from '../../state/mDirectList'; import { mDirectAtom } from '../../state/mDirectList';
import { roomToParentsAtom } from '../../state/room/roomToParents'; import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { plainToEditorInput, toPlainText } from '../../components/editor';
import { ShareRoomPicker } from '../../features/ShareRoomPicker'; import { ShareRoomPicker } from '../../features/ShareRoomPicker';
import { import {
isTauri, isTauri,
@@ -46,10 +44,10 @@ import {
} from '../../utils/backgroundSync'; } from '../../utils/backgroundSync';
import { import {
TUploadItem, TUploadItem,
roomIdToMsgDraftAtomFamily,
roomIdToUploadItemsAtomFamily, roomIdToUploadItemsAtomFamily,
} from '../../state/room/roomInputDrafts'; } from '../../state/room/roomInputDrafts';
import { encryptFile } from '../../utils/matrix'; import { fulfilledPromiseSettledResult } from '../../utils/common';
import { safeFile } from '../../utils/mimeTypes';
import { import {
AndroidSharePayload, AndroidSharePayload,
clearPendingAndroidShare, clearPendingAndroidShare,
@@ -517,10 +515,71 @@ function TaskbarFlashStopper() {
function AndroidShareIntentHandler() { function AndroidShareIntentHandler() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const store = useStore();
const navigate = useNavigate(); const navigate = useNavigate();
const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null); const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null);
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
const selectedRoomId = useSelectedRoom();
const selectedRoomIdRef = useRef(selectedRoomId);
selectedRoomIdRef.current = selectedRoomId;
/** Queue shared files into the open room's composer upload board (Gboard / paste / share). */
const queueShareAsDrafts = useCallback(
async (share: AndroidSharePayload, roomId: string) => {
const room = mx.getRoom(roomId);
if (!room) return false;
if (share.files.length > 0) {
const originalFiles: File[] = [];
for (const sharedFile of share.files) {
originalFiles.push(await materializeSharedFile(sharedFile, share.receivedAt));
}
const safeFiles = originalFiles.map(safeFile);
const fileItems: TUploadItem[] = [];
if (room.hasEncryptionStateEvent()) {
const encryptFiles = fulfilledPromiseSettledResult(
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
);
encryptFiles.forEach((ef) =>
fileItems.push({
...ef,
metadata: { markedAsSpoiler: false },
})
);
} else {
safeFiles.forEach((f) =>
fileItems.push({
file: f,
originalFile: f,
encInfo: undefined,
metadata: { markedAsSpoiler: false },
})
);
}
store.set(roomIdToUploadItemsAtomFamily(roomId), {
type: 'PUT',
item: fileItems,
});
}
const nextParts = [share.subject?.trim(), share.text?.trim()].filter(
(value): value is string => !!value
);
if (nextParts.length > 0) {
await mx.sendMessage(roomId, {
msgtype: 'm.text' as const,
body: nextParts.join('\n'),
});
}
await clearPendingAndroidShare();
return true;
},
[mx, store]
);
const applyPendingShare = useCallback( const applyPendingShare = useCallback(
async (share: AndroidSharePayload, roomId: string) => { async (share: AndroidSharePayload, roomId: string) => {
@@ -543,7 +602,7 @@ function AndroidShareIntentHandler() {
if (share.files.length > 0) { if (share.files.length > 0) {
for (const sharedFile of share.files) { for (const sharedFile of share.files) {
const originalFile = await materializeSharedFile(sharedFile, share.receivedAt); const originalFile = await materializeSharedFile(sharedFile, share.receivedAt);
let fileToUpload = originalFile; let fileToUpload = originalFile;
let encInfo: any = undefined; let encInfo: any = undefined;
@@ -594,6 +653,22 @@ function AndroidShareIntentHandler() {
[mx] [mx]
); );
const ingestShare = useCallback(
(share: AndroidSharePayload) => {
const roomId = selectedRoomIdRef.current;
// Already in a room: attach to composer (Gboard GIF / paste / in-app share).
if (roomId && share.files.length > 0) {
queueShareAsDrafts(share, roomId).catch((err) => {
console.error('[AndroidShare] Failed to queue share into room:', err);
setPendingShare(share);
});
return;
}
setPendingShare(share);
},
[queueShareAsDrafts]
);
const handlePickRoom = useCallback( const handlePickRoom = useCallback(
(roomId: string) => { (roomId: string) => {
if (!pendingShare) return; if (!pendingShare) return;
@@ -602,7 +677,7 @@ function AndroidShareIntentHandler() {
.then((applied) => { .then((applied) => {
if (applied) { if (applied) {
setPendingShare(null); setPendingShare(null);
// Navigate to the selected room // Navigate to the selected room
const isDirect = mDirects.has(roomId); const isDirect = mDirects.has(roomId);
if (isDirect) { if (isDirect) {
@@ -640,7 +715,7 @@ function AndroidShareIntentHandler() {
getPendingAndroidShare() getPendingAndroidShare()
.then((share) => { .then((share) => {
if (mounted && share) { if (mounted && share) {
setPendingShare(share); ingestShare(share);
} }
}) })
.catch((err) => { .catch((err) => {
@@ -649,7 +724,7 @@ function AndroidShareIntentHandler() {
listenForAndroidShares((share) => { listenForAndroidShares((share) => {
if (mounted) { if (mounted) {
setPendingShare(share); ingestShare(share);
} }
}) })
.then((handle) => { .then((handle) => {
@@ -663,7 +738,7 @@ function AndroidShareIntentHandler() {
mounted = false; mounted = false;
void listenerHandle?.remove(); void listenerHandle?.remove();
}; };
}, []); }, [ingestShare]);
if (!pendingShare) return null; if (!pendingShare) return null;

View File

@@ -1,6 +1,6 @@
{ {
"name": "paarrot", "name": "paarrot",
"version": "4.11.123", "version": "4.11.126",
"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"