Compare commits

...

27 Commits

Author SHA1 Message Date
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
GitHub Actions
b008011d5f chore: bump version to 4.11.95 [skip ci] 2026-05-12 22:42:45 +00:00
938f000f65 chore: remove extra newline in README.md
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 08:42:35 +10:00
GitHub Actions
7fdf369883 chore: bump version to 4.11.94 [skip ci] 2026-05-12 22:28:54 +00:00
c9883a1279 chore: remove extra newline in SIGNING_SETUP.md
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m49s
Build / create-release (push) Successful in 23s
2026-05-13 08:28:45 +10:00
GitHub Actions
5f09b0dd71 chore: bump version to 4.11.93 [skip ci] 2026-05-12 22:20:57 +00:00
0e436f076e 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 5m7s
Build / create-release (push) Successful in 14s
2026-05-13 08:20:49 +10:00
2281b31894 feat: add signing configuration for debug and release builds based on keystore properties 2026-05-13 08:20:47 +10:00
GitHub Actions
0eda439897 chore: bump version to 4.11.92 [skip ci] 2026-05-12 22:03:52 +00:00
a1bd03b277 chore: update subproject commit reference to 7ef9939
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m42s
Build / create-release (push) Successful in 14s
2026-05-13 08:03:43 +10:00
GitHub Actions
1c2904898f chore: bump version to 4.11.91 [skip ci] 2026-04-28 05:46:30 +00:00
0fe41a55c1 feat: add custom notification sound for message alerts in MatrixSyncService
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m56s
Build / create-release (push) Successful in 14s
2026-04-28 15:46:20 +10:00
af2f7dbcbc feat: enhance MatrixSyncService with user profile handling and image notifications 2026-04-26 18:54:58 +10:00
12 changed files with 628 additions and 58 deletions

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

@@ -1,5 +1,5 @@
# Android App Signing Setup
## Why This Matters
Android requires apps to be signed with the same key for updates to work. Without proper signing, users cannot update your app - they must uninstall and reinstall, losing all data.

View File

@@ -35,8 +35,16 @@ android {
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
if (keystorePropertiesFile.exists()) {
signingConfig signingConfigs.release
}
}
buildTypes {
debug {
if (keystorePropertiesFile.exists()) {
signingConfig signingConfigs.release
}
}
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'

View File

@@ -5,10 +5,14 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.media.AudioAttributes
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.util.Log
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@@ -37,6 +41,11 @@ class MatrixSyncService : Service() {
/** In-memory cache of MXID → display name to avoid redundant API calls per service run. */
private val displayNameCache = HashMap<String, String>(16)
/** In-memory cache of MXID → avatar bitmap (null = no avatar or fetch failed). */
private val avatarCache = HashMap<String, Bitmap?>(16)
private data class UserProfile(val displayName: String, val avatar: Bitmap?)
override fun onBind(intent: Intent?) = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -165,24 +174,45 @@ class MatrixSyncService : Service() {
if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue
val content = event.optJSONObject("content") ?: continue
val body = content.optString("body").takeIf { it.isNotBlank() } ?: continue
val sender = event.optString("sender")
val senderName = resolveDisplayName(sender, homeserver, token)
val msgtype = content.optString("msgtype")
val rawBody = content.optString("body")
val body = when (msgtype) {
"m.image" -> if (rawBody.isNotBlank()) "📷 $rawBody" else "📷 Photo"
"m.video" -> if (rawBody.isNotBlank()) "🎥 $rawBody" else "🎥 Video"
"m.audio" -> if (rawBody.isNotBlank()) "🎵 $rawBody" else "🎵 Audio"
"m.file" -> if (rawBody.isNotBlank()) "📎 $rawBody" else "📎 File"
"m.sticker" -> if (rawBody.isNotBlank()) "🖼️ $rawBody" else "🖼️ Sticker"
else -> rawBody.takeIf { it.isNotBlank() } ?: continue
}
showMessageNotification(nm, senderName, body)
val sender = event.optString("sender")
val profile = resolveProfile(sender, homeserver, token)
// For unencrypted image/sticker messages, try to download a preview bitmap.
// Encrypted messages have a `file` object instead of a top-level `url`.
val inlineImage: Bitmap? = if (
(msgtype == "m.image" || msgtype == "m.sticker") && !content.has("file")
) {
content.optString("url").takeIf { it.startsWith("mxc://") }
?.let { mxcToDownloadUrl(it, homeserver) }
?.let { downloadBitmap(it, token) }
} else null
showMessageNotification(nm, profile.displayName, body, profile.avatar, inlineImage)
}
}
}
private fun resolveDisplayName(mxid: String, homeserver: String, token: String): String {
displayNameCache[mxid]?.let { return it }
private fun resolveProfile(mxid: String, homeserver: String, token: String): UserProfile {
val cachedName = displayNameCache[mxid]
if (cachedName != null) return UserProfile(cachedName, avatarCache[mxid])
val fallback = mxid.substringAfter("@").substringBefore(":")
return try {
val encodedId = URLEncoder.encode(mxid, "UTF-8")
val url = "$homeserver/_matrix/client/v3/profile/$encodedId/displayname"
val url = "$homeserver/_matrix/client/v3/profile/$encodedId"
val conn = URL(url).openConnection() as HttpURLConnection
val name = try {
val (displayName, avatarMxc) = try {
conn.requestMethod = "GET"
conn.setRequestProperty("Authorization", "Bearer $token")
conn.setRequestProperty("Accept", "application/json")
@@ -190,22 +220,73 @@ class MatrixSyncService : Service() {
conn.readTimeout = 4_000
if (conn.responseCode == 200) {
val body = conn.inputStream.bufferedReader().readText()
JSONObject(body).optString("displayname").takeIf { it.isNotBlank() } ?: fallback
val obj = JSONObject(body)
val name = obj.optString("displayname").takeIf { it.isNotBlank() } ?: fallback
val mxc = obj.optString("avatar_url").takeIf { it.startsWith("mxc://") }
Pair(name, mxc)
} else {
fallback
Pair(fallback, null)
}
} finally {
conn.disconnect()
}
displayNameCache[mxid] = name
name
displayNameCache[mxid] = displayName
val avatar = avatarMxc
?.let { mxcToThumbnailUrl(it, homeserver, size = 96) }
?.let { downloadBitmap(it, token) }
avatarCache[mxid] = avatar
UserProfile(displayName, avatar)
} catch (e: Exception) {
Log.w(TAG, "Could not resolve display name for $mxid: ${e.message}")
fallback
Log.w(TAG, "Could not resolve profile for $mxid: ${e.message}")
displayNameCache[mxid] = fallback
avatarCache[mxid] = null
UserProfile(fallback, null)
}
}
private fun showMessageNotification(nm: NotificationManager, sender: String, body: String) {
private fun mxcToThumbnailUrl(mxcUrl: String, homeserver: String, size: Int): String? {
val withoutScheme = mxcUrl.removePrefix("mxc://")
val slash = withoutScheme.indexOf('/')
if (slash < 0) return null
val serverName = withoutScheme.substring(0, slash)
val mediaId = withoutScheme.substring(slash + 1)
return "$homeserver/_matrix/media/v3/thumbnail/$serverName/$mediaId?width=$size&height=$size&method=crop"
}
private fun mxcToDownloadUrl(mxcUrl: String, homeserver: String): String? {
val withoutScheme = mxcUrl.removePrefix("mxc://")
val slash = withoutScheme.indexOf('/')
if (slash < 0) return null
val serverName = withoutScheme.substring(0, slash)
val mediaId = withoutScheme.substring(slash + 1)
return "$homeserver/_matrix/media/v3/download/$serverName/$mediaId"
}
private fun downloadBitmap(urlString: String, token: String): Bitmap? {
return try {
val conn = URL(urlString).openConnection() as HttpURLConnection
try {
conn.requestMethod = "GET"
conn.setRequestProperty("Authorization", "Bearer $token")
conn.connectTimeout = 5_000
conn.readTimeout = 10_000
if (conn.responseCode == 200) BitmapFactory.decodeStream(conn.inputStream) else null
} finally {
conn.disconnect()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to download bitmap from $urlString: ${e.message}")
null
}
}
private fun showMessageNotification(
nm: NotificationManager,
sender: String,
body: String,
largeIcon: Bitmap? = null,
inlineImage: Bitmap? = null,
) {
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
?: Intent(this, MainActivity::class.java)
val pi = PendingIntent.getActivity(
@@ -213,16 +294,25 @@ class MatrixSyncService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(this, CHANNEL_MESSAGES)
val builder = NotificationCompat.Builder(this, CHANNEL_MESSAGES)
.setSmallIcon(R.drawable.ic_stat_paarrot)
.setContentTitle(sender)
.setContentText(body)
.setAutoCancel(true)
.setContentIntent(pi)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
nm.notify(System.currentTimeMillis().toInt(), notification)
if (largeIcon != null) builder.setLargeIcon(largeIcon)
if (inlineImage != null) {
builder.setStyle(
NotificationCompat.BigPictureStyle()
.bigPicture(inlineImage)
.bigLargeIcon(null as Bitmap?)
)
}
nm.notify(System.currentTimeMillis().toInt(), builder.build())
}
private fun buildStatusNotification(): Notification {
@@ -258,10 +348,20 @@ class MatrixSyncService : Service() {
private fun ensureMessageChannel(nm: NotificationManager) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val soundUri = Uri.parse("android.resource://$packageName/${R.raw.paarrot_notification}")
val channel = NotificationChannel(
CHANNEL_MESSAGES, "Messages",
NotificationManager.IMPORTANCE_HIGH,
).apply { description = "Matrix message notifications" }
).apply {
description = "Matrix message notifications"
setSound(
soundUri,
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build(),
)
}
nm.createNotificationChannel(channel)
}
}
@@ -278,7 +378,7 @@ class MatrixSyncService : Service() {
private const val KEY_LAST_WAKE_MS = "last_wake_ms"
private const val NOTIF_ID_STATUS = 1001
private const val CHANNEL_STATUS = "sync_status"
private const val CHANNEL_MESSAGES = "messages"
private const val CHANNEL_MESSAGES = "messages_paarrot"
private const val MIN_WAKE_INTERVAL_MS = 7_500L
const val MODE_ONE_SHOT = "one_shot"

Binary file not shown.

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: ef0aa2a847...7ef9939d8a

View File

@@ -0,0 +1,269 @@
/* 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,
} 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 autocorrect replacement that causes text duplication
if (inputEvent.inputType === 'insertReplacementText' ||
inputEvent.inputType === 'insertFromComposition') {
const { selection } = editor;
if (!selection) return;
// Get the data being inserted
const data = inputEvent.data || inputEvent.dataTransfer?.getData('text/plain');
if (data) {
event.preventDefault();
// If there's selected text, delete it first
if (selection && !Range.isCollapsed(selection)) {
Transforms.delete(editor, { at: selection });
}
// Insert the replacement text
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

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

@@ -517,86 +517,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(() => {

View File

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