Compare commits

..

37 Commits

Author SHA1 Message Date
GitHub Actions
8b51d78db6 chore: bump version to 4.11.111 [skip ci] 2026-07-08 17:23:31 +00:00
e27ec9d7ab feat: add Android typography fix for Capacitor apps and integrate into ClientNonUIFeatures
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m30s
Build / create-release (push) Successful in 14s
2026-07-09 03:23:22 +10:00
GitHub Actions
5c18e38314 chore: bump version to 4.11.110 [skip ci] 2026-07-08 16:43:01 +00:00
64e1de954a fix: update subproject commit reference in cinny to b425827
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m12s
Build / create-release (push) Successful in 14s
2026-07-09 02:42:52 +10:00
GitHub Actions
0404cdc8ee chore: bump version to 4.11.109 [skip ci] 2026-07-08 16:07:17 +00:00
9dc177f527 fix: update version code and name in build.gradle; register PluginStoragePlugin in MainActivity; enhance text duplication handling in Editor component
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m15s
Build / create-release (push) Successful in 15s
2026-07-09 02:07:03 +10:00
655603bb59 fix: update subproject commit reference in cinny to f67f08f1 2026-07-09 02:06:56 +10:00
GitHub Actions
fb9e8bc277 chore: bump version to 4.11.108 [skip ci] 2026-05-13 01:12:28 +00:00
803a076f98 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m34s
Build / create-release (push) Successful in 14s
2026-05-13 11:12:22 +10:00
86d284b353 fix: update subproject commit reference in cinny 2026-05-13 11:12:17 +10:00
6207d18b3c fix: update version code and name in build.gradle; adjust Modal component in ShareRoomPicker 2026-05-13 11:12:09 +10:00
GitHub Actions
8d2e35d1bc chore: bump version to 4.11.107 [skip ci] 2026-05-13 00:40:41 +00:00
6845398755 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m32s
Build / create-release (push) Successful in 14s
2026-05-13 10:40:34 +10:00
be52b0fa08 fix(mobile): update version code and name, adjust padding in ShareRoomPicker component 2026-05-13 10:40:31 +10:00
GitHub Actions
298bcfd1e3 chore: bump version to 4.11.106 [skip ci] 2026-05-13 00:24:11 +00:00
fce1736312 fix(mobile): increase top padding to 60px for status bar, reduce dialog height
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m44s
Build / create-release (push) Successful in 14s
2026-05-13 10:24:02 +10:00
GitHub Actions
e11304d093 chore: bump version to 4.11.105 [skip ci] 2026-05-13 00:15:56 +00:00
60d76d5165 feat(mobile): add confirmation dialog back to share picker with backdrop
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m35s
Build / create-release (push) Successful in 14s
2026-05-13 10:15:47 +10:00
GitHub Actions
373e700a07 chore: bump version to 4.11.104 [skip ci] 2026-05-13 00:13:42 +00:00
f7b25261cc fix(mobile): make share picker fullscreen with full-width search, remove confirmation dialog
Some checks failed
Build / increment-version (push) Successful in 6s
Build / build-android (push) Has started running
Build / create-release (push) Has been cancelled
2026-05-13 10:13:35 +10:00
GitHub Actions
41e1b0db9d chore: bump version to 4.11.103 [skip ci] 2026-05-13 00:08:24 +00:00
d1022635d2 feat(mobile): improve share dialog UI with padding, full-width search, and confirmation
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m30s
Build / create-release (push) Successful in 14s
2026-05-13 10:08:16 +10:00
GitHub Actions
2e6be4b0b3 chore: bump version to 4.11.102 [skip ci] 2026-05-12 23:59:14 +00:00
0d97676edd fix(mobile): send shared content immediately instead of staging in draft
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-05-13 09:59:07 +10:00
GitHub Actions
f8e8058de3 chore: bump version to 4.11.101 [skip ci] 2026-05-12 23:54:07 +00:00
a1577a3b97 fix(mobile): navigate to room after share picker selection
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m28s
Build / create-release (push) Successful in 15s
2026-05-13 09:53:57 +10:00
GitHub Actions
80f1df6588 chore: bump version to 4.11.100 [skip ci] 2026-05-12 23:44:23 +00:00
375fa74309 fix: restore live server validation while keeping input stable during typing
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m48s
Build / create-release (push) Successful in 14s
2026-05-13 09:44:14 +10:00
GitHub Actions
0862094e00 chore: bump version to 4.11.99 [skip ci] 2026-05-12 23:41:58 +00:00
45d66bbe28 fix(mobile): prevent server input focus loss and text modification while typing
Some checks failed
Build / increment-version (push) Successful in 6s
Build / create-release (push) Has been cancelled
Build / build-android (push) Has been cancelled
2026-05-13 09:41:49 +10:00
GitHub Actions
919bc310a7 chore: bump version to 4.11.98 [skip ci] 2026-05-12 23:32:30 +00:00
badcfb160c fix(mobile): handle Android keyboard autocorrect text duplication in overlay
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m15s
Build / create-release (push) Successful in 13s
2026-05-13 09:32:22 +10:00
GitHub Actions
1b35f71bb8 chore: bump version to 4.11.97 [skip ci] 2026-05-12 22:59:45 +00:00
96349bf200 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m26s
Build / create-release (push) Successful in 14s
2026-05-13 08:59:38 +10:00
472d30940d chore: remove extra whitespace in README.md 2026-05-13 08:59:36 +10:00
GitHub Actions
ae9d2bfeb6 chore: bump version to 4.11.96 [skip ci] 2026-05-12 22:52:08 +00:00
61b82d1f40 fix: use consistent signing cert for all builds to prevent update conflicts
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m53s
Build / create-release (push) Successful in 14s
2026-05-13 08:51:57 +10:00
18 changed files with 1423 additions and 166 deletions

View File

@@ -1,7 +1,7 @@
# Paarrot
Paarrot is a Matrix client focusing primarily on simple, elegant and secure interface. The desktop app is built with Electron and based on Cinny.
## Download
Installers for Windows and Linux can be downloaded from [releases](http://synbox.ruv.wtf:8418/litruv/cinny-desktop/releases).

4
android/.gitignore vendored
View File

@@ -52,10 +52,8 @@ captures/
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files - NEVER commit these!
# Keystore files
*.jks
*.keystore
keystore.properties
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild

View File

@@ -27,8 +27,8 @@ android {
applicationId "com.paarrot.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1777176952
versionName "2026-04-26.041552.151"
versionCode 1783526741
versionName "2026-07-08.160541.766"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -8,6 +8,7 @@ public class MainActivity extends BridgeActivity {
protected void onCreate(Bundle savedInstanceState) {
registerPlugin(SyncServicePlugin.class);
registerPlugin(ShareHandlerPlugin.class);
registerPlugin(PluginStoragePlugin.class);
super.onCreate(savedInstanceState);
}
}

View File

@@ -0,0 +1,250 @@
package com.paarrot.app
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.ZipInputStream
/** Capacitor plugin that stores and manages user extension plugins on Android. */
@CapacitorPlugin(name = "PluginStorage")
class PluginStoragePlugin : Plugin() {
private fun pluginsDir(): File {
val dir = File(context.filesDir, "plugins")
dir.mkdirs()
return dir
}
@PluginMethod
fun getPath(call: PluginCall) {
val result = JSObject()
result.put("success", true)
result.put("data", pluginsDir().absolutePath)
call.resolve(result)
}
@PluginMethod
fun list(call: PluginCall) {
try {
val plugins = pluginsDir().listFiles()?.filter { it.isDirectory } ?: emptyList()
val array = JSArray()
for (dir in plugins) {
val metadata = readMetadata(dir)
val obj = JSObject()
obj.put("id", dir.name)
obj.put("name", metadata?.optString("name", dir.name) ?: dir.name)
obj.put("version", metadata?.optString("version", "") ?: "")
obj.put("description", metadata?.optString("description", "") ?: "")
obj.put("author", metadata?.optString("author", "") ?: "")
obj.put("repository", metadata?.optString("repository", "") ?: "")
obj.put("thumbnail", metadata?.optString("thumbnail", "") ?: "")
obj.put("homepage", metadata?.optString("homepage", "") ?: "")
obj.put("tags", metadataTagsToJsArray(metadata))
obj.put("installedDate", formatInstalledDate(dir.lastModified()))
obj.put("path", dir.absolutePath)
array.put(obj)
}
val result = JSObject()
result.put("success", true)
result.put("data", array)
call.resolve(result)
} catch (e: Exception) {
val result = JSObject()
result.put("success", false)
result.put("error", e.message ?: "Failed to list plugins")
call.resolve(result)
}
}
@PluginMethod
fun download(call: PluginCall) {
val pluginId = call.getString("pluginId")
?: return call.reject("pluginId required")
val downloadUrl = call.getString("downloadUrl")
?: return call.reject("downloadUrl required")
val name = call.getString("name") ?: pluginId
Thread {
try {
val destDir = File(pluginsDir(), pluginId)
if (destDir.exists()) {
destDir.deleteRecursively()
}
destDir.mkdirs()
val tempZip = File(context.cacheDir, "plugin-download-$pluginId.zip")
downloadFile(downloadUrl, tempZip)
extractZip(tempZip, destDir)
tempZip.delete()
normalisePluginLayout(destDir)
if (!File(destDir, "index.js").exists()) {
val result = JSObject()
result.put("success", false)
result.put("error", "Downloaded plugin is missing index.js")
call.resolve(result)
return@Thread
}
val result = JSObject()
result.put("success", true)
val data = JSObject()
data.put("path", destDir.absolutePath)
result.put("data", data)
call.resolve(result)
} catch (e: Exception) {
val result = JSObject()
result.put("success", false)
result.put("error", e.message ?: "Failed to download plugin: $name")
call.resolve(result)
}
}.start()
}
@PluginMethod
fun uninstall(call: PluginCall) {
val pluginId = call.getString("pluginId")
?: return call.reject("pluginId required")
try {
val destDir = File(pluginsDir(), pluginId)
if (destDir.exists()) {
destDir.deleteRecursively()
}
val result = JSObject()
result.put("success", true)
call.resolve(result)
} catch (e: Exception) {
val result = JSObject()
result.put("success", false)
result.put("error", e.message ?: "Failed to uninstall plugin")
call.resolve(result)
}
}
@PluginMethod
fun readPluginCode(call: PluginCall) {
val pluginId = call.getString("pluginId")
?: return call.reject("pluginId required")
val indexFile = File(pluginsDir(), "$pluginId/index.js")
if (!indexFile.exists()) {
val result = JSObject()
result.put("success", false)
result.put("error", "Plugin not found: $pluginId")
call.resolve(result)
return
}
val result = JSObject()
result.put("success", true)
result.put("data", indexFile.readText())
call.resolve(result)
}
private fun readMetadata(pluginDir: File): JSONObject? {
val metadataFile = File(pluginDir, "plugin-metadata.json")
if (!metadataFile.exists()) return null
return try {
JSONObject(metadataFile.readText())
} catch (_: Exception) {
null
}
}
private fun metadataTagsToJsArray(metadata: JSONObject?): JSArray {
val tags = JSArray()
val jsonTags = metadata?.optJSONArray("tags") ?: return tags
for (i in 0 until jsonTags.length()) {
tags.put(jsonTags.optString(i))
}
return tags
}
private fun formatInstalledDate(timestamp: Long): String {
val formatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
return formatter.format(Date(timestamp))
}
private fun downloadFile(downloadUrl: String, destFile: File) {
val connection = (URL(downloadUrl).openConnection() as HttpURLConnection).apply {
connectTimeout = 30_000
readTimeout = 60_000
instanceFollowRedirects = true
requestMethod = "GET"
}
try {
connection.connect()
if (connection.responseCode !in 200..299) {
throw IllegalStateException("HTTP ${connection.responseCode}")
}
connection.inputStream.use { input ->
FileOutputStream(destFile).use { output ->
input.copyTo(output)
}
}
} finally {
connection.disconnect()
}
}
private fun extractZip(zipFile: File, destDir: File) {
destDir.mkdirs()
val destPath = destDir.canonicalPath + File.separator
ZipInputStream(zipFile.inputStream()).use { zipInput ->
var entry = zipInput.nextEntry
while (entry != null) {
val outFile = File(destDir, entry.name)
if (!outFile.canonicalPath.startsWith(destPath)) {
throw SecurityException("Zip entry outside target directory")
}
if (entry.isDirectory) {
outFile.mkdirs()
} else {
outFile.parentFile?.mkdirs()
FileOutputStream(outFile).use { output ->
zipInput.copyTo(output)
}
}
zipInput.closeEntry()
entry = zipInput.nextEntry
}
}
}
/** Flatten single-root zip layouts so index.js ends up directly under the plugin dir. */
private fun normalisePluginLayout(destDir: File) {
if (File(destDir, "index.js").exists()) return
val children = destDir.listFiles()?.filter { it.isDirectory } ?: return
if (children.size != 1) return
val nested = children[0]
nested.listFiles()?.forEach { child ->
child.renameTo(File(destDir, child.name))
}
nested.deleteRecursively()
}
}

View File

@@ -0,0 +1,4 @@
storeFile=../paarrot-release.keystore
storePassword=paarrot2026
keyAlias=paarrot
keyPassword=paarrot2026

Binary file not shown.

2
cinny

Submodule cinny updated: 7ef9939d8a...b4258278d8

View File

@@ -0,0 +1,287 @@
/* eslint-disable no-param-reassign */
import React, {
ClipboardEventHandler,
KeyboardEventHandler,
ReactNode,
forwardRef,
useCallback,
useState,
useEffect,
useRef,
} from 'react';
import { Box, Scroll, Text } from 'folds';
import { Descendant, Editor, createEditor, Transforms, Range, Element as SlateElement, Text as SlateText, Point } from 'slate';
import {
Slate,
Editable,
withReact,
RenderLeafProps,
RenderElementProps,
RenderPlaceholderProps,
ReactEditor,
} from 'slate-react';
import { withHistory } from 'slate-history';
import { BlockType } from './types';
import { RenderElement, RenderLeaf } from './Elements';
import { CustomElement } from './slate';
import * as css from './Editor.css';
import { toggleKeyboardShortcut } from './keyboard';
import { createCommandElement } from './utils';
const initialValue: CustomElement[] = [
{
type: BlockType.Paragraph,
children: [{ text: '' }],
},
];
const withInline = (editor: Editor): Editor => {
const { isInline } = editor;
editor.isInline = (element) =>
[BlockType.Mention, BlockType.Emoticon, BlockType.Link, BlockType.Command].includes(
element.type
) || isInline(element);
return editor;
};
const withVoid = (editor: Editor): Editor => {
const { isVoid } = editor;
editor.isVoid = (element) =>
[BlockType.Mention, BlockType.Emoticon, BlockType.Command].includes(element.type) ||
isVoid(element);
return editor;
};
type CommandValidatorFn = (commandName: string) => boolean;
let commandValidator: CommandValidatorFn | null = null;
export const setCommandValidator = (validator: CommandValidatorFn) => {
commandValidator = validator;
};
const withCommandAutoConvert = (editor: Editor): Editor => {
// Removed auto-conversion on space to prevent focus issues
// Commands are now highlighted visually via decorations
// and executed when Enter is pressed
return editor;
};
export const useEditor = (): Editor => {
const [editor] = useState(() =>
withCommandAutoConvert(withInline(withVoid(withReact(withHistory(createEditor())))))
);
return editor;
};
export type EditorChangeHandler = (value: Descendant[]) => void;
type CustomEditorProps = {
editableName?: string;
top?: ReactNode;
bottom?: ReactNode;
before?: ReactNode;
after?: ReactNode;
maxHeight?: string;
editor: Editor;
placeholder?: string;
onKeyDown?: KeyboardEventHandler;
onKeyUp?: KeyboardEventHandler;
onChange?: EditorChangeHandler;
onPaste?: ClipboardEventHandler;
};
export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
(
{
editableName,
top,
bottom,
before,
after,
maxHeight = '50vh',
editor,
placeholder,
onKeyDown,
onKeyUp,
onChange,
onPaste,
},
ref
) => {
const renderElement = useCallback(
(props: RenderElementProps) => <RenderElement {...props} />,
[]
);
const renderLeaf = useCallback((props: RenderLeafProps) => <RenderLeaf {...props} />, []);
const decorate = useCallback(([node, path]: [any, number[]]) => {
const ranges: any[] = [];
// Only decorate text nodes in the first paragraph
if (
path.length === 2 &&
path[0] === 0 &&
path[1] === 0 &&
SlateText.isText(node)
) {
const firstChild = editor.children[0];
if (SlateElement.isElement(firstChild) && firstChild.type === BlockType.Paragraph) {
const [firstInline, secondInline] = firstChild.children;
// Don't decorate if we already have a CommandElement
if (SlateElement.isElement(secondInline) && secondInline.type === BlockType.Command) {
return ranges;
}
// Check if the text matches /command pattern
const text = node.text;
const match = text.match(/^(\s*\/\w+)/);
if (match && commandValidator) {
const commandText = match[1].trim();
const commandName = commandText.substring(1);
if (commandValidator(commandName)) {
// Create decoration for the command text
ranges.push({
anchor: { path, offset: 0 },
focus: { path, offset: match[1].length },
pendingCommand: true,
});
}
}
}
}
return ranges;
}, [editor]);
const handleKeydown: KeyboardEventHandler = useCallback(
(evt) => {
onKeyDown?.(evt);
const shortcutToggled = toggleKeyboardShortcut(editor, evt);
if (shortcutToggled) evt.preventDefault();
},
[editor, onKeyDown]
);
const handleBeforeInput = useCallback(
(event: Event) => {
const inputEvent = event as InputEvent;
// Handle mobile autocorrect replacement that causes text duplication in Slate
if (inputEvent.inputType === 'insertReplacementText') {
const data = inputEvent.data || inputEvent.dataTransfer?.getData('text/plain');
if (!data) return;
event.preventDefault();
const domRanges = inputEvent.getTargetRanges?.() ?? [];
if (domRanges.length > 0) {
const slateRange = ReactEditor.toSlateRange(editor, domRanges[0], {
exactMatch: false,
suppressThrow: true,
});
if (slateRange) {
Transforms.select(editor, slateRange);
Transforms.delete(editor);
editor.insertText(data);
return;
}
}
const { selection } = editor;
if (!selection) return;
if (!Range.isCollapsed(selection)) {
Transforms.delete(editor, { at: selection });
} else {
const wordBefore = Editor.before(editor, selection.anchor, { unit: 'word' });
if (wordBefore) {
const wordRange = { anchor: wordBefore, focus: selection.anchor };
Transforms.select(editor, wordRange);
Transforms.delete(editor);
}
}
editor.insertText(data);
}
},
[editor]
);
const editableRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const editableElement = editableRef.current?.querySelector('[data-slate-editor="true"]');
if (!editableElement) return;
editableElement.addEventListener('beforeinput', handleBeforeInput, { capture: true });
return () => {
editableElement.removeEventListener('beforeinput', handleBeforeInput, { capture: true });
};
}, [handleBeforeInput]);
const renderPlaceholder = useCallback(
({ attributes, children }: RenderPlaceholderProps) => (
<span {...attributes} className={css.EditorPlaceholderContainer}>
{/* Inner component to style the actual text position and appearance */}
<Text as="span" className={css.EditorPlaceholderTextVisual} truncate>
{children}
</Text>
</span>
),
[]
);
return (
<div className={css.Editor} ref={ref}>
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
{top}
<Box alignItems="Start">
{before && (
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
{before}
</Box>
)}
<Scroll
className={css.EditorTextareaScroll}
variant="SurfaceVariant"
style={{ maxHeight }}
size="300"
visibility="Hover"
hideTrack
ref={editableRef}
>
<Editable
data-editable-name={editableName}
className={css.EditorTextarea}
placeholder={placeholder}
renderPlaceholder={renderPlaceholder}
renderElement={renderElement}
renderLeaf={renderLeaf}
decorate={decorate}
onKeyDown={handleKeydown}
onKeyUp={onKeyUp}
onPaste={onPaste}
/>
</Scroll>
{after && (
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
{after}
</Box>
)}
</Box>
{bottom}
</Slate>
</div>
);
}
);

View File

@@ -5,6 +5,7 @@ import FocusTrap from 'focus-trap-react';
import {
Avatar,
Box,
Button,
Header,
Icon,
IconButton,
@@ -62,6 +63,7 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const scrollRef = useRef<HTMLDivElement>(null);
const [confirmRoomId, setConfirmRoomId] = useState<string | null>(null);
const mDirects = useAtomValue(mDirectAtom);
const allRoomsSet = useAllJoinedRoomsSet();
@@ -107,149 +109,208 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
const handleRoomClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
const roomId = evt.currentTarget.getAttribute('data-room-id');
if (roomId) {
onPick(roomId);
setConfirmRoomId(roomId);
}
};
const handleConfirm = useCallback(() => {
if (confirmRoomId) {
onPick(confirmRoomId);
}
}, [confirmRoomId, onPick]);
const handleCancelConfirm = useCallback(() => {
setConfirmRoomId(null);
}, []);
const previewText = share.text?.slice(0, 80) ?? share.subject?.slice(0, 80);
const fileCount = share.files.length;
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: onDismiss,
escapeDeactivates: stopPropagation,
<>
<Overlay open>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: onDismiss,
escapeDeactivates: stopPropagation,
}}
>
<Box
grow="Yes"
direction="Column"
style={{
position: 'fixed',
inset: 0,
zIndex: 999,
backgroundColor: '#1a1d23',
paddingTop: '60px',
}}
>
<Modal size="300">
<Box grow="Yes" direction="Column" style={{ maxHeight: '85vh' }}>
<Header
size="500"
style={{ padding: config.space.S200, paddingLeft: config.space.S400 }}
>
<Box grow="Yes" direction="Column" gap="100">
<Text size="H4">Share to Room</Text>
{(previewText || fileCount > 0) && (
<Text size="T200" priority="300" style={{ opacity: 0.7 }}>
{previewText
? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"`
: `${fileCount} file${fileCount !== 1 ? 's' : ''}`}
</Text>
)}
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={onDismiss}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Header>
<Header
size="500"
style={{ padding: config.space.S400 }}
>
<Box grow="Yes" direction="Column" gap="100" style={{ minWidth: 0, maxWidth: '80%' }}>
<Text size="H4">Share to Room</Text>
{(previewText || fileCount > 0) && (
<Text size="T200" priority="300" style={{ opacity: 0.7, wordBreak: 'break-word', whiteSpace: 'normal' }}>
{previewText
? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"`
: `${fileCount} file${fileCount !== 1 ? 's' : ''}`}
</Text>
)}
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={onDismiss}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Header>
<Box
style={{
padding: `0 ${config.space.S300}`,
paddingTop: config.space.S200,
paddingBottom: config.space.S200,
}}
>
<Input
onChange={handleSearchChange}
before={<Icon size="200" src={Icons.Search} />}
placeholder="Search rooms…"
size="400"
variant="Background"
outlined
autoFocus
/>
</Box>
<Box
style={{
padding: config.space.S200,
paddingTop: config.space.S300,
}}
>
<Input
onChange={handleSearchChange}
before={<Icon size="200" src={Icons.Search} />}
placeholder="Search rooms…"
size="400"
variant="Background"
outlined
autoFocus
style={{ width: '100%' }}
/>
</Box>
<Scroll ref={scrollRef} size="300" hideTrack style={{ flex: 1, minHeight: 0 }}>
<Scroll ref={scrollRef} size="300" hideTrack style={{ flex: 1, minHeight: 0 }}>
<Box style={{ padding: config.space.S300 }} direction="Column">
{vItems.length === 0 && (
<Box
style={{ padding: config.space.S300, paddingTop: 0 }}
style={{ padding: `${config.space.S700} 0` }}
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="100"
>
{vItems.length === 0 && (
<Box
style={{ padding: `${config.space.S700} 0` }}
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="100"
<Text size="H6" align="Center">
{searchResult ? 'No Match Found' : 'No Rooms'}
</Text>
<Text size="T200" align="Center" priority="300">
{searchResult
? `No rooms found for "${searchResult.query}".`
: 'You have no rooms to share into.'}
</Text>
</Box>
)}
<Box style={{ position: 'relative', height: virtualizer.getTotalSize() }}>
{vItems.map((vItem) => {
const roomId = items[vItem.index];
const room = getRoom(roomId);
if (!room) return null;
const isDm = mDirects.has(roomId);
const avatarSrc = isDm
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingBottom: config.space.S100 }}
ref={virtualizer.measureElement}
key={roomId}
>
<Text size="H6" align="Center">
{searchResult ? 'No Match Found' : 'No Rooms'}
</Text>
<Text size="T200" align="Center" priority="300">
{searchResult
? `No rooms found for "${searchResult.query}".`
: 'You have no rooms to share into.'}
</Text>
</Box>
)}
<Box
style={{ position: 'relative', height: virtualizer.getTotalSize() }}
>
{vItems.map((vItem) => {
const roomId = items[vItem.index];
const room = getRoom(roomId);
if (!room) return null;
const isDm = mDirects.has(roomId);
const avatarSrc = isDm
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingBottom: config.space.S100 }}
ref={virtualizer.measureElement}
key={roomId}
>
<MenuItem
data-room-id={roomId}
onClick={handleRoomClick}
variant="Surface"
size="400"
radii="400"
before={
<Avatar size="300" radii={isDm ? '400' : '300'}>
{avatarSrc ? (
<RoomAvatar
roomId={roomId}
src={avatarSrc}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/>
) : (
<RoomIcon room={room} size="200" filled />
<MenuItem
data-room-id={roomId}
onClick={handleRoomClick}
variant="Surface"
size="400"
radii="400"
before={
<Avatar size="300" radii={isDm ? '400' : '300'}>
{avatarSrc ? (
<RoomAvatar
roomId={roomId}
src={avatarSrc}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
</Avatar>
}
>
<Box grow="Yes" direction="Column">
<Text size="B400" truncate>
{room.name}
</Text>
</Box>
</MenuItem>
</VirtualTile>
);
})}
/>
) : (
<RoomIcon room={room} size="200" filled />
)}
</Avatar>
}
>
<Box grow="Yes" direction="Column">
<Text size="B400" truncate>
{room.name}
</Text>
</Box>
</MenuItem>
</VirtualTile>
);
})}
</Box>
</Box>
</Scroll>
</Box>
</FocusTrap>
</Overlay>
{confirmRoomId && (() => {
const room = getRoom(confirmRoomId);
const roomName = room?.name ?? confirmRoomId;
const isDm = mDirects.has(confirmRoomId);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleCancelConfirm,
escapeDeactivates: stopPropagation,
}}
>
<Modal style={{ maxWidth: '280px' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S300 }}>
<Box direction="Column" gap="100">
<Text size="H4">Share to {isDm ? 'Person' : 'Room'}?</Text>
<Text size="T300" priority="300">
Send to <strong>{roomName}</strong>?
</Text>
</Box>
<Box gap="200" justifyContent="End">
<Button
onClick={handleCancelConfirm}
variant="Secondary"
size="400"
>
<Text size="B400">Cancel</Text>
</Button>
<Button
onClick={handleConfirm}
variant="Primary"
size="400"
>
<Text size="B400">Share</Text>
</Button>
</Box>
</Box>
</Scroll>
</Box>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
})()}
</>
);
}

View File

@@ -0,0 +1,149 @@
/**
* Re-exports the full @paarrot/plugin-manager API and creates the shared
* singleton PluginRegistry instance for this application.
*
* All other modules in this folder should import from here rather than
* directly from the package so the singleton is guaranteed to be shared.
*/
export {
PluginMarketplaceClient,
PluginMarketplaceManager,
PluginRegistry,
createPluginContext,
generateThemeCSS,
MemoryStorage,
} from '@paarrot/plugin-manager';
export type {
IPluginStorage,
IPluginEventClient,
PluginMarketplaceClientOptions,
PluginMarketplaceHostAdapter,
PluginMarketplaceInstalledRecord,
PluginMarketplaceManagerOptions,
PluginRegistryOptions,
PluginContextOptions,
PluginMetadata,
InstalledPlugin,
PluginIndex,
ThemeColorGroup,
ThemePaletteGroup,
PluginThemeColors,
PluginTheme,
CommandArg,
PluginCommand,
MessageInterceptor,
MessageContext,
CustomRenderer,
SettingDefinition,
SettingsSchema,
NotificationOptions,
PluginContext,
PluginSettingsSection,
UILocation,
UIButtonDefinition,
UIButtonPosition,
Plugin,
PluginLogEntry,
} from '@paarrot/plugin-manager';
export { PluginTab } from '@paarrot/plugin-manager';
import {
PluginMarketplaceClient,
PluginMarketplaceManager,
PluginRegistry,
} from '@paarrot/plugin-manager';
import { getPluginHost } from '../../../utils/pluginHost';
const PLUGIN_INDEX_URL =
import.meta.env.VITE_PLUGIN_INDEX_URL ||
`https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/index.json?cache_burst=${(Math.random()*256).toString()}`;
const PLUGIN_BASE_URL =
import.meta.env.VITE_PLUGIN_BASE_URL ||
'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/';
function injectThemeStyle(styleId: string, css: string): void {
let el = document.getElementById(styleId) as HTMLStyleElement | null;
if (!el) {
el = document.createElement('style');
el.id = styleId;
document.head.appendChild(el);
}
el.textContent = css;
}
function removeThemeStyle(styleId: string): void {
document.getElementById(styleId)?.remove();
}
/** Shared singleton registry for the entire application. */
export const pluginRegistry = new PluginRegistry({
storage: localStorage,
onThemeRegistered: (themeId, _className, css) =>
injectThemeStyle(`plugin-theme-${themeId}`, css),
onThemeUnregistered: (themeId) => removeThemeStyle(`plugin-theme-${themeId}`),
});
/** Shared marketplace client for remote plugin directory reads. */
export const pluginMarketplaceClient = new PluginMarketplaceClient({
indexUrl: PLUGIN_INDEX_URL,
baseUrl: PLUGIN_BASE_URL,
});
/**
* Shared marketplace manager for install state, directory fetch, and enable flags.
*/
export const pluginMarketplaceManager = new PluginMarketplaceManager({
client: pluginMarketplaceClient,
storage: localStorage,
registry: pluginRegistry,
host: {
listInstalledPlugins: async () => {
const host = getPluginHost();
if (!host) {
return [];
}
const result = await host.list();
if (!result.success || !result.data) {
throw new Error(result.error ?? 'Failed to list installed plugins');
}
return result.data.map((plugin) => ({
id: plugin.id,
name: plugin.name,
version: plugin.version,
description: plugin.description,
author: plugin.author,
repository: plugin.repository,
thumbnail: plugin.thumbnail,
homepage: plugin.homepage,
tags: plugin.tags,
installedDate: plugin.installedDate,
}));
},
installPlugin: async (plugin) => {
const host = getPluginHost();
if (!host) {
return;
}
const result = await host.download(plugin.id, plugin.downloadUrl, plugin.name);
if (!result.success) {
throw new Error(result.error ?? 'Failed to install plugin');
}
},
uninstallPlugin: async (pluginId) => {
const host = getPluginHost();
if (!host) {
return;
}
const result = await host.uninstall(pluginId);
if (!result.success) {
throw new Error(result.error ?? 'Failed to uninstall plugin');
}
},
},
});

View File

@@ -0,0 +1,190 @@
import React, { useEffect } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { Plugin, createPluginContext } from '@paarrot/plugin-manager';
import { pluginMarketplaceManager, pluginRegistry } from './PluginAPI';
import { sendNotification } from '../../../utils/tauri';
import { getPluginHost } from '../../../utils/pluginHost';
import { dispatchPluginButtonsChanged } from './PluginButtonSlot';
interface PluginLoaderProps {
matrixClient: MatrixClient;
children: React.ReactNode;
}
/**
* Component that loads and initializes enabled plugins.
* Should be placed high in the component tree after MatrixClient is available.
*/
export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
console.log('[PluginLoader] Component rendering, matrixClient:', !!matrixClient);
useEffect(() => {
const loadPlugins = async () => {
try {
const pluginHost = getPluginHost();
console.log('[PluginLoader] Loading plugins...');
console.log('[PluginLoader] plugin host available:', !!pluginHost);
if (!pluginHost) {
console.log('[PluginLoader] Running in web mode, plugins not supported');
return;
}
const pluginsToLoad = await pluginMarketplaceManager.listEnabledPlugins();
console.log('[PluginLoader] Plugins to load:', pluginsToLoad.length);
console.log('[PluginLoader] Plugin list:', pluginsToLoad.map((p) => p.id));
for (const installedPlugin of pluginsToLoad) {
try {
console.log(`[PluginLoader] Loading plugin: ${installedPlugin.id}`);
const pluginModule = await loadPluginModule(installedPlugin.id);
const plugin = normalisePlugin(pluginModule);
if (!plugin || typeof plugin !== 'object') {
console.error(
`[PluginLoader] Plugin ${installedPlugin.id} does not export a valid plugin object`
);
continue;
}
if (typeof plugin.onLoad !== 'function') {
console.error(
`[PluginLoader] Plugin ${installedPlugin.id} does not have an onLoad function`
);
continue;
}
const context = createPluginContext(
{
pluginId: installedPlugin.id,
eventClient: matrixClient as any,
onNotify: async (opts) => {
await sendNotification({
title: opts.title,
body: opts.body,
path: undefined,
});
},
},
pluginRegistry
);
const compatContext = Object.assign(context as Record<string, unknown>, {
matrixClient,
matrix: {
on: (eventType: string, handler: (...args: unknown[]) => void) =>
matrixClient.on(eventType as any, handler as any),
off: (eventType: string, handler: (...args: unknown[]) => void) =>
matrixClient.off(eventType as any, handler as any),
},
React,
});
console.log('[PluginLoader] compatContext.React:', !!compatContext.React);
console.log('[PluginLoader] compatContext.ui:', !!compatContext.ui);
console.log('[PluginLoader] About to register plugin and call onLoad...');
pluginRegistry.registerPlugin(installedPlugin.id, plugin, context);
await plugin.onLoad(compatContext as any);
console.log(`[PluginLoader] Successfully loaded plugin: ${installedPlugin.id}`);
dispatchPluginButtonsChanged();
} catch (error) {
console.error(`[PluginLoader] Failed to load plugin ${installedPlugin.id}:`, error);
pluginRegistry.addLog(installedPlugin.id, 'error', ['Failed to load:', error]);
}
}
} catch (error) {
console.error('[PluginLoader] Error loading plugins:', error);
}
};
loadPlugins();
return () => {
console.log('[PluginLoader] Cleaning up plugins');
pluginRegistry.clear();
dispatchPluginButtonsChanged();
};
}, [matrixClient]);
return <>{children}</>;
}
/**
* Reads and evaluates a plugin's JS module from the native filesystem host.
* Supports both CommonJS and ESM-style exports.
*/
async function loadPluginModule(pluginId: string): Promise<any> {
const pluginHost = getPluginHost();
if (!pluginHost) {
throw new Error('Plugins are only supported in the desktop or Android app');
}
const response = await pluginHost.readPluginCode(pluginId);
if (!response.success || !response.data) {
throw new Error(response.error ?? 'Failed to read plugin code');
}
const pluginExports: any = {};
const mod = { exports: pluginExports };
try {
// eslint-disable-next-line no-new-func
const pluginFunction = new Function('module', 'exports', response.data);
pluginFunction(mod, pluginExports);
} catch (error) {
if (!(error instanceof SyntaxError)) {
throw error;
}
const dataUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(response.data)}`;
const importedModule = await import(/* @vite-ignore */ dataUrl);
return importedModule;
}
return mod.exports;
}
/**
* Normalizes raw plugin exports to current plugin interface.
* Supports native `onLoad` and legacy `activate` / `deactivate` plugins.
*/
function normalisePlugin(raw: Record<string, unknown>): Plugin {
if (typeof raw.onLoad === 'function') {
return raw as unknown as Plugin;
}
if (typeof raw.activate === 'function') {
const activate = raw.activate as (ctx: unknown) => void | Promise<void>;
const deactivate = raw.deactivate as ((ctx?: unknown) => void | Promise<void>) | undefined;
return {
name: typeof raw.name === 'string' ? raw.name : undefined,
version: typeof raw.version === 'string' ? raw.version : undefined,
onLoad: async (ctx) => {
const legacyCtx = Object.assign(Object.create(ctx as object), {
registerHook: (name: string) =>
ctx.log(`[compat] registerHook("${name}") - not supported`),
runHook: (name: string) => {
ctx.log(`[compat] runHook("${name}")`);
return Promise.resolve([]);
},
getConfig: (key: string) => ctx.settings.get(key),
on: (event: string, handler: (...args: unknown[]) => void) =>
ctx.events.on(event, handler),
});
await activate(legacyCtx);
},
onUnload: deactivate
? async () => {
await deactivate();
}
: undefined,
};
}
return raw as unknown as Plugin;
}

View File

@@ -0,0 +1,159 @@
import React, {
ChangeEventHandler,
FocusEventHandler,
KeyboardEventHandler,
MouseEventHandler,
useEffect,
useRef,
useState,
} from 'react';
import {
Header,
Icon,
IconButton,
Icons,
Input,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
config,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useDebounce } from '../../hooks/useDebounce';
import { stopPropagation } from '../../utils/keyboard';
export function ServerPicker({
server,
serverList,
allowCustomServer,
onServerChange,
}: {
server: string;
serverList: string[];
allowCustomServer?: boolean;
onServerChange: (server: string) => void;
}) {
const [serverMenuAnchor, setServerMenuAnchor] = useState<RectCords>();
const serverInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// Only sync input when server changes externally (e.g., from menu selection)
// and input is not focused to avoid cursor jumping during typing
if (
serverInputRef.current &&
serverInputRef.current.value !== server &&
document.activeElement !== serverInputRef.current
) {
serverInputRef.current.value = server;
}
}, [server]);
const debounceServerSelect = useDebounce(onServerChange, { wait: 700 });
const handleServerChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const inputServer = evt.target.value.trim();
if (inputServer) debounceServerSelect(inputServer);
};
const handleBlur: FocusEventHandler<HTMLInputElement> = (evt) => {
const inputServer = evt.target.value.trim();
if (inputServer && inputServer !== server) {
onServerChange(inputServer);
}
};
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
if (evt.key === 'ArrowDown') {
evt.preventDefault();
setServerMenuAnchor(undefined);
}
if (evt.key === 'Enter') {
evt.preventDefault();
const inputServer = evt.currentTarget.value.trim();
if (inputServer) onServerChange(inputServer);
}
};
const handleServerSelect: MouseEventHandler<HTMLButtonElement> = (evt) => {
const selectedServer = evt.currentTarget.getAttribute('data-server');
if (selectedServer) {
onServerChange(selectedServer);
}
setServerMenuAnchor(undefined);
};
const handleOpenServerMenu: MouseEventHandler<HTMLElement> = (evt) => {
const target = evt.currentTarget.parentElement ?? evt.currentTarget;
setServerMenuAnchor(target.getBoundingClientRect());
};
return (
<Input
ref={serverInputRef}
style={{ paddingRight: config.space.S200 }}
variant={allowCustomServer ? 'Background' : 'Surface'}
outlined
defaultValue={server}
onChange={handleServerChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
size="500"
readOnly={!allowCustomServer}
onClick={allowCustomServer ? undefined : handleOpenServerMenu}
after={
serverList.length === 0 || (serverList.length === 1 && !allowCustomServer) ? undefined : (
<PopOut
anchor={serverMenuAnchor}
position="Bottom"
align="End"
offset={4}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setServerMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
<Text size="L400">Homeserver List</Text>
</Header>
<div style={{ padding: config.space.S100, paddingTop: 0 }}>
{serverList?.map((serverName) => (
<MenuItem
key={serverName}
radii="300"
aria-pressed={serverName === server}
data-server={serverName}
onClick={handleServerSelect}
>
<Text>{serverName}</Text>
</MenuItem>
))}
</div>
</Menu>
</FocusTrap>
}
>
<IconButton
onClick={handleOpenServerMenu}
variant={allowCustomServer ? 'Background' : 'Surface'}
size="300"
aria-pressed={!!serverMenuAnchor}
radii="300"
>
<Icon src={Icons.ChevronBottom} />
</IconButton>
</PopOut>
)
}
/>
);
}

View File

@@ -35,9 +35,11 @@ import {
isTauri,
isElectron,
isCapacitorNative,
applyAndroidTypographyFix,
sendNotification,
setupNotificationTapListener,
} from '../../utils/tauri';
import '../../styles/android-typography.css';
import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api';
import {
startBackgroundSync,
@@ -84,6 +86,14 @@ function EmojiStyleFeature() {
return null;
}
function AndroidTypographyFeature() {
useEffect(() => {
applyAndroidTypographyFix();
}, []);
return null;
}
function PageZoomFeature() {
const [pageZoom] = useSetting(settingsAtom, 'pageZoom');
@@ -517,86 +527,118 @@ function TaskbarFlashStopper() {
function AndroidShareIntentHandler() {
const mx = useMatrixClient();
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
const navigate = useNavigate();
const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null);
const [pickedRoomId, setPickedRoomId] = useState<string | null>(null);
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(pickedRoomId ?? '__android_share__'));
const setUploadItems = useSetAtom(roomIdToUploadItemsAtomFamily(pickedRoomId ?? '__android_share__'));
const mDirects = useAtomValue(mDirectAtom);
const roomToParents = useAtomValue(roomToParentsAtom);
const applyPendingShare = useCallback(
async (share: AndroidSharePayload, roomId: string) => {
const room = mx.getRoom(roomId);
if (!room) return false;
// Send text message if present
const nextParts = [share.subject?.trim(), share.text?.trim()].filter(
(value): value is string => !!value
);
if (nextParts.length > 0) {
const existingText = toPlainText(msgDraft, isMarkdown).trim();
const nextText = existingText ? `${existingText}\n${nextParts.join('\n')}` : nextParts.join('\n');
setMsgDraft(plainToEditorInput(nextText, isMarkdown));
const textContent = {
msgtype: 'm.text' as const,
body: nextParts.join('\n'),
};
await mx.sendMessage(roomId, textContent);
}
// Upload and send files if present
if (share.files.length > 0) {
const uploadItems: TUploadItem[] = [];
for (const sharedFile of share.files) {
const originalFile = await materializeSharedFile(sharedFile, share.receivedAt);
let fileToUpload = originalFile;
let encInfo: any = undefined;
if (room.hasEncryptionStateEvent()) {
const encrypted = await encryptFile(originalFile);
uploadItems.push({
file: encrypted.file,
originalFile,
metadata: { markedAsSpoiler: false },
encInfo: encrypted.encInfo,
});
continue;
fileToUpload = encrypted.file;
encInfo = encrypted.encInfo;
}
uploadItems.push({
file: originalFile,
originalFile,
metadata: { markedAsSpoiler: false },
encInfo: undefined,
});
}
// Upload file
const uploadResult = await mx.uploadContent(fileToUpload);
const mxc = uploadResult?.content_uri;
if (!mxc) continue;
setUploadItems({
type: 'PUT',
item: uploadItems,
});
// Determine message type and send
const fileType = originalFile.type;
let msgtype = 'm.file' as const;
if (fileType.startsWith('image/')) msgtype = 'm.image' as const;
else if (fileType.startsWith('video/')) msgtype = 'm.video' as const;
else if (fileType.startsWith('audio/')) msgtype = 'm.audio' as const;
const fileContent: any = {
msgtype,
body: originalFile.name,
filename: originalFile.name,
info: {
mimetype: originalFile.type,
size: originalFile.size,
},
};
if (encInfo) {
fileContent.file = {
...encInfo,
url: mxc,
};
} else {
fileContent.url = mxc;
}
await mx.sendMessage(roomId, fileContent);
}
}
await clearPendingAndroidShare();
return true;
},
[isMarkdown, msgDraft, mx, setMsgDraft, setUploadItems]
[mx]
);
const handlePickRoom = useCallback(
(roomId: string) => {
setPickedRoomId(roomId);
if (!pendingShare) return;
applyPendingShare(pendingShare, roomId)
.then((applied) => {
if (applied) {
setPendingShare(null);
setPickedRoomId(null);
// Navigate to the selected room
const isDirect = mDirects.has(roomId);
if (isDirect) {
navigate(getDirectRoomPath(roomId));
} else {
const parents = roomToParents.get(roomId);
const parent = parents && parents.length > 0 ? parents[0] : undefined;
if (parent) {
navigate(getSpaceRoomPath(parent, roomId));
} else {
navigate(getHomeRoomPath(roomId));
}
}
}
})
.catch((err) => {
console.error('[AndroidShare] Failed to apply share after pick:', err);
setPendingShare(null);
setPickedRoomId(null);
});
},
[applyPendingShare, pendingShare]
[applyPendingShare, pendingShare, navigate, mDirects, roomToParents]
);
const handleDismiss = useCallback(() => {
clearPendingAndroidShare().catch(() => {});
setPendingShare(null);
setPickedRoomId(null);
}, []);
useEffect(() => {
@@ -652,6 +694,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
return (
<>
<EmojiStyleFeature />
<AndroidTypographyFeature />
<PageZoomFeature />
<FaviconUpdater />
<InviteNotifications />

View File

@@ -0,0 +1,8 @@
/*
* Android WebView renders Inter Variable with broken word/letter spacing.
* Applied when html.android-capacitor is set (see applyAndroidTypographyFix).
*/
html.android-capacitor body {
letter-spacing: normal;
word-spacing: normal;
}

View File

@@ -0,0 +1,72 @@
import { Capacitor, registerPlugin } from '@capacitor/core';
export type PluginHostListItem = {
id: string;
name: string;
version?: string;
description?: string;
author?: string;
repository?: string;
thumbnail?: string;
homepage?: string;
tags?: string[];
installedDate: string;
path: string;
};
export type PluginHostResult<T> = {
success: boolean;
data?: T;
error?: string;
};
export interface PluginHostAPI {
getPath: () => Promise<PluginHostResult<string>>;
download: (
pluginId: string,
downloadUrl: string,
name: string
) => Promise<PluginHostResult<{ path: string }>>;
list: () => Promise<PluginHostResult<PluginHostListItem[]>>;
uninstall: (pluginId: string) => Promise<PluginHostResult<void>>;
readPluginCode: (pluginId: string) => Promise<PluginHostResult<string>>;
}
interface PluginStoragePlugin {
getPath(): Promise<PluginHostResult<string>>;
download(options: {
pluginId: string;
downloadUrl: string;
name: string;
}): Promise<PluginHostResult<{ path: string }>>;
list(): Promise<PluginHostResult<PluginHostListItem[]>>;
uninstall(options: { pluginId: string }): Promise<PluginHostResult<void>>;
readPluginCode(options: { pluginId: string }): Promise<PluginHostResult<string>>;
}
const PluginStorage = registerPlugin<PluginStoragePlugin>('PluginStorage');
const androidPluginHost: PluginHostAPI = {
getPath: () => PluginStorage.getPath(),
download: (pluginId, downloadUrl, name) =>
PluginStorage.download({ pluginId, downloadUrl, name }),
list: () => PluginStorage.list(),
uninstall: (pluginId) => PluginStorage.uninstall({ pluginId }),
readPluginCode: (pluginId) => PluginStorage.readPluginCode({ pluginId }),
};
/** Returns true when a native plugin filesystem host is available. */
export const isPluginHostSupported = (): boolean => getPluginHost() !== null;
/** Returns the active plugin host for Electron or Android. */
export const getPluginHost = (): PluginHostAPI | null => {
if (window.electron?.plugins) {
return window.electron.plugins;
}
if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android') {
return androidPluginHost;
}
return null;
};

View File

@@ -248,6 +248,41 @@ export const isAndroid = (): boolean => {
return ua.includes('android');
};
/** Returns true when running as a Capacitor app on Android. */
export const isCapacitorAndroid = (): boolean => {
if (typeof window === 'undefined') return false;
const cap = (window as { Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string } }).Capacitor;
return Boolean(cap?.isNativePlatform?.() && cap?.getPlatform?.() === 'android');
};
/**
* Android WebView mishandles Inter Variable — swap to static Inter / system fonts.
*/
export const applyAndroidTypographyFix = (): void => {
if (!isCapacitorAndroid()) return;
const root = document.documentElement;
root.classList.add('android-capacitor');
void (async () => {
try {
await import('@fontsource/inter/400.css');
await import('@fontsource/inter/500.css');
await import('@fontsource/inter/600.css');
root.style.setProperty(
'--font-secondary',
`'Inter', system-ui, Roboto, 'Noto Sans', var(--font-emoji), sans-serif`
);
} catch (err) {
console.warn('[applyAndroidTypographyFix] Falling back to system font:', err);
root.style.setProperty(
'--font-secondary',
`system-ui, Roboto, 'Noto Sans', var(--font-emoji), sans-serif`
);
}
})();
};
/**
* Apply safe area insets for mobile devices
* On Android, CSS env() may not work, so we apply fallback padding

View File

@@ -1,6 +1,6 @@
{
"name": "paarrot",
"version": "4.11.95",
"version": "4.11.111",
"description": "Paarrot - A Matrix client based on Cinny",
"engines": {
"node": ">=18.0.0"