Compare commits

...

57 Commits

Author SHA1 Message Date
GitHub Actions
4618d59605 chore: bump version to 4.11.128 [skip ci] 2026-07-14 17:20:29 +00:00
697f03bc51 fix: bump cinny for Inter Variable / emoji space metrics
All checks were successful
Build / increment-version (push) Successful in 8s
Build / build-android (push) Successful in 4m52s
Build / create-release (push) Successful in 14s
Point at cinny 154f4dfd (correct Inter Variable name + emoji unicode-range)
and keep the mobile overlay font stack in sync.
2026-07-15 03:20:21 +10:00
GitHub Actions
670aaf6a20 chore: bump version to 4.11.127 [skip ci] 2026-07-11 12:46:09 +00:00
c4a6b4b175 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 4m52s
Build / create-release (push) Successful in 14s
2026-07-11 22:46:00 +10:00
a5efb1f337 chore: update subproject status to dirty 2026-07-11 22:45:48 +10:00
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
GitHub Actions
70d6f6ff42 chore: bump version to 4.11.123 [skip ci] 2026-07-11 11:24:56 +00:00
97f0a15ae2 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 4m50s
Build / create-release (push) Successful in 14s
2026-07-11 21:24:47 +10:00
4cf19131bf chore: mark subproject as dirty and update package-lock.json with new version and dependencies 2026-07-11 21:24:39 +10:00
GitHub Actions
83137c92a9 chore: bump version to 4.11.122 [skip ci] 2026-07-11 11:18:23 +00:00
426ddf5650 Merge branch 'master' of http://synbox.ruv.wtf:8418/litruv/cinny-mobile
Some checks failed
Build / increment-version (push) Successful in 7s
Build / build-android (push) Failing after 1m0s
Build / create-release (push) Has been skipped
2026-07-11 21:18:12 +10:00
7800e7ced7 refactor: implement immersive mode for mobile keyboard layout and update related styles 2026-07-11 21:18:06 +10:00
GitHub Actions
a5afa6b493 chore: bump version to 4.11.121 [skip ci] 2026-07-11 11:03:38 +00:00
afeb45f59d 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 5m9s
Build / create-release (push) Successful in 14s
2026-07-11 21:03:28 +10:00
6f45f94f19 refactor: adjust RoomView component for improved mobile composer behavior and styling 2026-07-11 21:03:18 +10:00
GitHub Actions
fbcb291b9a chore: bump version to 4.11.120 [skip ci] 2026-07-11 10:49:12 +00:00
b7ec975fb0 refactor: update mobile gesture styles to use global styles for better maintainability
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 5m11s
Build / create-release (push) Successful in 14s
2026-07-11 20:48:54 +10:00
GitHub Actions
3f5d5dec98 chore: bump version to 4.11.119 [skip ci] 2026-07-11 10:36:49 +00:00
c7b6e8f5f0 refactor: enhance mobile swipe gesture handling and improve outlet behavior on compact routes
Some checks failed
Build / increment-version (push) Successful in 6s
Build / build-android (push) Failing after 1m32s
Build / create-release (push) Has been skipped
2026-07-11 20:36:10 +10:00
GitHub Actions
f709998c50 chore: bump version to 4.11.118 [skip ci] 2026-07-09 09:12:12 +00:00
ddcda65c1f 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 4m19s
Build / create-release (push) Successful in 14s
2026-07-09 19:12:04 +10:00
6e32bf302a chore: hide scrollbars on mobile devices for better user experience 2026-07-09 19:11:53 +10:00
GitHub Actions
ca51f93ecd chore: bump version to 4.11.117 [skip ci] 2026-07-09 08:48:12 +00:00
47c525cc5a 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 4m24s
Build / create-release (push) Successful in 14s
2026-07-09 18:48:04 +10:00
a282fce14a chore: update cinny submodule to latest dirty commit and refactor mobile swipe gesture handling 2026-07-09 18:47:55 +10:00
GitHub Actions
f1121839d4 chore: bump version to 4.11.116 [skip ci] 2026-07-09 08:06:48 +00:00
585eeecb1b 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 5m1s
Build / create-release (push) Successful in 15s
2026-07-09 18:06:32 +10:00
72dbbe29b9 chore: update cinny submodule to latest commit with mobile gesture enhancements and disable route animation on compact view 2026-07-09 18:06:06 +10:00
GitHub Actions
b6000c1db8 chore: bump version to 4.11.115 [skip ci] 2026-07-09 07:42:59 +00:00
83b4cbbd8e chore: update cinny submodule to latest commit and modify versioning for Android app
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-07-09 17:42:28 +10:00
GitHub Actions
8753ed2600 chore: bump version to 4.11.114 [skip ci] 2026-07-09 07:23:56 +00:00
cced6b8ab9 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 4m41s
Build / create-release (push) Successful in 15s
2026-07-09 17:23:47 +10:00
82effc9680 chore: update cinny submodule to latest commit for improved navigation and routing 2026-07-09 17:23:45 +10:00
GitHub Actions
deefb40277 chore: bump version to 4.11.113 [skip ci] 2026-07-09 06:56:00 +00:00
b496c70190 chore: bump cinny submodule to 9c7f7189 (compact nav and back routing)
All checks were successful
Build / increment-version (push) Successful in 7s
Build / build-android (push) Successful in 4m33s
Build / create-release (push) Successful in 16s
2026-07-09 16:55:52 +10:00
GitHub Actions
5a4384ea1b chore: bump version to 4.11.112 [skip ci] 2026-07-08 17:41:01 +00:00
cc974414f8 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 4m12s
Build / create-release (push) Successful in 14s
2026-07-09 03:40:53 +10:00
3245f41073 refactor: remove Android typography fix and associated styles from ClientNonUIFeatures 2026-07-09 03:40:51 +10:00
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
42 changed files with 5247 additions and 39 deletions

View File

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

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;
import android.os.Bundle;
import android.webkit.WebView;
import androidx.core.view.ViewCompat;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
@@ -8,6 +10,20 @@ public class MainActivity extends BridgeActivity {
protected void onCreate(Bundle savedInstanceState) {
registerPlugin(SyncServicePlugin.class);
registerPlugin(ShareHandlerPlugin.class);
registerPlugin(PluginStoragePlugin.class);
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

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

@@ -62,7 +62,6 @@ object ShareIntentStore {
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return
val receivedAt = System.currentTimeMillis()
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
@@ -86,8 +85,29 @@ object ShareIntentStore {
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) }
if (copiedFiles.isEmpty() && text.isNullOrBlank() && subject.isNullOrBlank()) return
val payload = SharePayload(
text = text,
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>

2
cinny

Submodule cinny updated: b2fd65c8cb...154f4dfdb0

View File

@@ -1,3 +1,5 @@
{
"dependencies": {}
"dependencies": {
"@capacitor/status-bar": "8.0.1"
}
}

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { useCompactNav, useIsCompactListRoute } from '../hooks/useCompactNav';
import { MobileSwipeGestureHost } from './mobile/MobileSwipeGestureHost';
/**
* Outlet wrapper for route remounts + compact swipe-back chrome.
*
* On compact list routes the parent PageRoot already omits children. If this
* outlet is still mounted (stale stack / empty SpaceIndexRedirect), render
* nothing so an empty swipe shell cannot sit above the channel list and eat taps.
*/
export function AnimatedOutlet() {
const location = useLocation();
const compact = useCompactNav();
const isListRoute = useIsCompactListRoute();
if (compact && isListRoute) {
return null;
}
return (
<MobileSwipeGestureHost>
<div
key={location.pathname}
{...(!compact && { 'data-route-transition': 'true' })}
style={{
flex: 1,
minWidth: 0,
minHeight: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Outlet />
</div>
</MobileSwipeGestureHost>
);
}

View File

@@ -0,0 +1,79 @@
import { style } from '@vanilla-extract/css';
import { color, config, DefaultReset, toRem } from 'folds';
export const Editor = style([
DefaultReset,
{
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R400,
overflow: 'hidden',
selectors: {
// Keyboard-flush dock: only a top edge so the input can sit on the IME.
'[data-composer-flush="true"] &': {
borderRadius: 0,
boxShadow: `inset 0 ${config.borderWidth.B300} 0 0 ${color.SurfaceVariant.ContainerLine}`,
},
},
},
]);
export const EditorOptions = style([
DefaultReset,
{
padding: config.space.S200,
},
]);
export const EditorTextareaScroll = style({});
export const EditorTextarea = style([
DefaultReset,
{
flexGrow: 1,
height: '100%',
padding: `${toRem(13)} ${toRem(1)}`,
selectors: {
[`${EditorTextareaScroll}:first-child &`]: {
paddingLeft: toRem(13),
},
[`${EditorTextareaScroll}:last-child &`]: {
paddingRight: toRem(13),
},
'&:focus': {
outline: 'none',
},
},
},
]);
export const EditorPlaceholderContainer = style([
DefaultReset,
{
opacity: config.opacity.Placeholder,
pointerEvents: 'none',
userSelect: 'none',
},
]);
export const EditorPlaceholderTextVisual = style([
DefaultReset,
{
display: 'block',
paddingTop: toRem(13),
paddingLeft: toRem(1),
},
]);
export const EditorToolbarBase = style({
padding: `0 ${config.borderWidth.B300}`,
});
export const EditorToolbar = style({
padding: config.space.S100,
});
export const MarkdownBtnBox = style({
paddingRight: config.space.S100,
});

View File

@@ -18,6 +18,7 @@ import {
RenderLeafProps,
RenderElementProps,
RenderPlaceholderProps,
ReactEditor,
} from 'slate-react';
import { withHistory } from 'slate-history';
import { BlockType } from './types';
@@ -172,27 +173,44 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
const handleBeforeInput = useCallback(
(event: Event) => {
const inputEvent = event as InputEvent;
// Handle autocorrect replacement that causes text duplication
if (inputEvent.inputType === 'insertReplacementText' ||
inputEvent.inputType === 'insertFromComposition') {
// 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;
// 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 });
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);
}
// Insert the replacement text
editor.insertText(data);
}
editor.insertText(data);
}
},
[editor]

View File

@@ -0,0 +1,367 @@
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { useCompactNav } from '../../hooks/useCompactNav';
import { useBackRoute } from '../../hooks/useBackRoute';
import { startReplyToEvent } from '../../features/room/replyToMessage';
import { mobileSwipeReplyBridgeRef } from './mobileSwipeReplyBridge';
import * as css from './mobile-gestures.css';
const EDGE_WIDTH = 28;
const BACK_COMMIT_RATIO = 0.25;
const BACK_COMMIT_MIN = 56;
const REPLY_THRESHOLD = 48;
const REPLY_MAX = 96;
const AXIS_LOCK_PX = 10;
const AXIS_DOMINANCE = 1.2;
const MAX_START_Y_RATIO = 0.88;
type GesturePhase = 'idle' | 'pending' | 'back' | 'reply';
type GestureState = {
phase: GesturePhase;
touchId: number;
startX: number;
startY: number;
messageEl: HTMLElement | null;
transformEl: HTMLElement | null;
messageId: string | null;
edgeBack: boolean;
offset: number;
};
const IDLE_STATE: GestureState = {
phase: 'idle',
touchId: -1,
startX: 0,
startY: 0,
messageEl: null,
transformEl: null,
messageId: null,
edgeBack: false,
offset: 0,
};
type MobileSwipeGestureHostProps = {
children: ReactNode;
};
const shouldIgnoreBackTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return true;
return Boolean(
target.closest(
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-back="true"]'
)
);
};
const shouldIgnoreReplyTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof Element)) return true;
return Boolean(
target.closest(
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-reply="true"]'
)
);
};
const findMessageElement = (target: EventTarget | null): HTMLElement | null => {
if (!(target instanceof Element)) return null;
return target.closest('[data-message-id]') as HTMLElement | null;
};
const findReplyTransformTarget = (messageEl: HTMLElement): HTMLElement => {
const content = messageEl.querySelector('[data-swipe-reply-content]');
if (content instanceof HTMLElement) return content;
return messageEl;
};
const applyBackOffset = (dx: number, width: number): number => {
const positive = Math.max(0, dx);
if (positive <= width) return positive;
return width + (positive - width) * 0.22;
};
const setElementTransform = (el: HTMLElement | null, px: number) => {
if (!el) return;
el.style.transition = 'none';
el.style.transform = `translateX(${px}px)`;
};
const resetElementTransform = (el: HTMLElement | null) => {
if (!el) return;
el.style.transition = 'none';
el.style.transform = '';
};
export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps) {
const compact = useCompactNav();
const { pathname } = useLocation();
const { canGoBack, goBack } = useBackRoute();
const backEnabled = compact && canGoBack;
const replyEnabled = compact;
const rootRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const gestureRef = useRef<GestureState>({ ...IDLE_STATE });
const scrollLockRef = useRef<HTMLElement | null>(null);
const unlockScroll = useCallback(() => {
if (scrollLockRef.current) {
scrollLockRef.current.style.overflow = '';
scrollLockRef.current = null;
}
}, []);
const lockScroll = useCallback((anchor: HTMLElement) => {
unlockScroll();
let el: HTMLElement | null = anchor;
while (el && el !== document.body) {
const style = window.getComputedStyle(el);
if (
(style.overflowY === 'auto' || style.overflowY === 'scroll') &&
el.scrollHeight > el.clientHeight + 1
) {
scrollLockRef.current = el;
el.style.overflow = 'hidden';
return;
}
el = el.parentElement;
}
}, [unlockScroll]);
const resetBackTransform = useCallback(() => {
resetElementTransform(contentRef.current);
document.documentElement.classList.remove('mobile-gesture-lock');
}, []);
const resetReplyTransform = useCallback((transformEl?: HTMLElement | null) => {
const bridge = mobileSwipeReplyBridgeRef.current;
const el = transformEl ?? gestureRef.current.transformEl;
resetElementTransform(el);
bridge?.setIndicator(null, false);
document.documentElement.classList.remove('mobile-gesture-lock');
}, []);
const resetGesture = useCallback(
(transformEl?: HTMLElement | null) => {
resetBackTransform();
resetReplyTransform(transformEl);
unlockScroll();
gestureRef.current = { ...IDLE_STATE };
},
[resetBackTransform, resetReplyTransform, unlockScroll]
);
const updateReplyIndicator = useCallback((messageEl: HTMLElement, offset: number) => {
const bridge = mobileSwipeReplyBridgeRef.current;
if (!bridge?.layerEl) return;
const messageRect = messageEl.getBoundingClientRect();
const layerRect = bridge.layerEl.getBoundingClientRect();
const top = messageRect.top - layerRect.top + messageRect.height / 2 - 18;
bridge.setIndicator(top, Math.abs(offset) >= REPLY_THRESHOLD * 0.6);
}, []);
const applyReplyTransform = useCallback(
(transformEl: HTMLElement, messageEl: HTMLElement, offset: number) => {
setElementTransform(transformEl, offset);
updateReplyIndicator(messageEl, offset);
},
[updateReplyIndicator]
);
const lockGesture = useCallback(
(phase: 'back' | 'reply', scrollAnchor?: HTMLElement | null) => {
gestureRef.current.phase = phase;
document.documentElement.classList.add('mobile-gesture-lock');
if (scrollAnchor) {
lockScroll(scrollAnchor);
}
},
[lockScroll]
);
const finishGesture = useCallback(() => {
const gesture = gestureRef.current;
if (gesture.phase === 'idle' || gesture.phase === 'pending') {
resetGesture();
return;
}
if (gesture.phase === 'back') {
const width = rootRef.current?.clientWidth ?? window.innerWidth;
const shouldCommit =
gesture.offset >= Math.max(width * BACK_COMMIT_RATIO, BACK_COMMIT_MIN);
resetGesture();
if (shouldCommit) {
goBack();
}
return;
}
if (gesture.phase === 'reply' && gesture.messageEl && gesture.messageId) {
const bridge = mobileSwipeReplyBridgeRef.current;
const shouldReply = Math.abs(gesture.offset) >= REPLY_THRESHOLD;
const { messageEl, messageId, transformEl } = gesture;
resetGesture(transformEl);
if (shouldReply && bridge) {
startReplyToEvent(bridge.room, messageId, bridge.setReplyDraft, bridge.editor);
}
}
}, [goBack, resetGesture]);
useEffect(() => {
if (!compact) return;
const getActiveTouch = (evt: TouchEvent, touchId: number) =>
Array.from(evt.touches).find((touch) => touch.identifier === touchId);
const getEndedTouch = (evt: TouchEvent, touchId: number) =>
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) => {
if (gestureRef.current.phase !== 'idle') return;
const touch = evt.changedTouches[0];
if (!touch) return;
const target = evt.target;
if (touch.clientY > window.innerHeight * MAX_START_Y_RATIO) return;
const messageEl = findMessageElement(target);
const messageId = messageEl?.getAttribute('data-message-id') ?? null;
// 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 =
replyEnabled &&
messageEl &&
messageId &&
!shouldIgnoreReplyTarget(target) &&
mobileSwipeReplyBridgeRef.current;
if (!canStartBack && !canStartReply) return;
const contentLeft = rootRef.current?.getBoundingClientRect().left ?? 0;
const nearContentLeftEdge =
touch.clientX >= contentLeft && touch.clientX <= contentLeft + EDGE_WIDTH;
// Prefer pending over immediate back+preventDefault so a tap still clicks.
gestureRef.current = {
phase: 'pending',
touchId: touch.identifier,
startX: touch.clientX,
startY: touch.clientY,
messageEl: canStartReply ? messageEl : null,
transformEl: canStartReply && messageEl ? findReplyTransformTarget(messageEl) : null,
messageId: canStartReply ? messageId : null,
edgeBack: nearContentLeftEdge && canStartBack,
offset: 0,
};
};
const handleTouchMove = (evt: TouchEvent) => {
const gesture = gestureRef.current;
if (gesture.phase === 'idle') return;
const touch = getActiveTouch(evt, gesture.touchId);
if (!touch) return;
const deltaX = touch.clientX - gesture.startX;
const deltaY = touch.clientY - gesture.startY;
if (gesture.phase === 'pending') {
if (Math.abs(deltaX) < AXIS_LOCK_PX && Math.abs(deltaY) < AXIS_LOCK_PX) return;
const horizontal = Math.abs(deltaX) >= Math.abs(deltaY) * AXIS_DOMINANCE;
const vertical = Math.abs(deltaY) >= Math.abs(deltaX) * AXIS_DOMINANCE;
if (vertical) {
resetGesture();
return;
}
if (!horizontal) return;
if (deltaX > 0 && backEnabled) {
lockGesture('back', contentRef.current);
} else if (deltaX < 0 && gesture.messageEl && gesture.messageId && gesture.transformEl) {
lockGesture('reply', gesture.messageEl);
} else {
resetGesture();
return;
}
}
if (gesture.phase === 'back') {
if (!evt.cancelable) return;
evt.preventDefault();
const width = rootRef.current?.clientWidth ?? window.innerWidth;
const offset = applyBackOffset(deltaX, width);
gesture.offset = offset;
setElementTransform(contentRef.current, offset);
return;
}
if (gesture.phase === 'reply' && gesture.transformEl && gesture.messageEl) {
if (!evt.cancelable) return;
evt.preventDefault();
const offset = Math.max(deltaX, -REPLY_MAX);
gesture.offset = offset;
applyReplyTransform(gesture.transformEl, gesture.messageEl, offset);
}
};
const handleTouchEnd = (evt: TouchEvent) => {
const gesture = gestureRef.current;
if (gesture.phase === 'idle') return;
const touch = getEndedTouch(evt, gesture.touchId);
if (!touch) return;
finishGesture();
};
document.addEventListener('touchstart', handleTouchStart, { capture: true, passive: false });
document.addEventListener('touchmove', handleTouchMove, { capture: true, passive: false });
document.addEventListener('touchend', handleTouchEnd, { capture: true });
document.addEventListener('touchcancel', handleTouchEnd, { capture: true });
return () => {
document.removeEventListener('touchstart', handleTouchStart, { capture: true });
document.removeEventListener('touchmove', handleTouchMove, { capture: true });
document.removeEventListener('touchend', handleTouchEnd, { capture: true });
document.removeEventListener('touchcancel', handleTouchEnd, { capture: true });
resetGesture();
};
}, [backEnabled, compact, finishGesture, lockGesture, replyEnabled, resetGesture, applyReplyTransform]);
useEffect(() => {
resetGesture();
}, [canGoBack, pathname, resetGesture]);
if (!compact) {
return <>{children}</>;
}
return (
<div ref={rootRef} className={css.SwipeBackRoot}>
<div ref={contentRef} className={css.SwipeBackContent}>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import React, { ReactNode, useLayoutEffect, useRef, useState } from 'react';
import { Room } from 'matrix-js-sdk';
import { Editor } from 'slate';
import { useSetAtom } from 'jotai';
import { Icon, Icons } from '../icons';
import { useCompactNav } from '../../hooks/useCompactNav';
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { mobileSwipeReplyBridgeRef } from './mobileSwipeReplyBridge';
import * as css from './mobile-gestures.css';
type MobileSwipeToReplyLayerProps = {
room: Room;
editor: Editor;
children: ReactNode;
};
export function MobileSwipeToReplyLayer({ room, editor, children }: MobileSwipeToReplyLayerProps) {
const compact = useCompactNav();
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
const layerRef = useRef<HTMLDivElement>(null);
const [indicatorTop, setIndicatorTop] = useState<number | null>(null);
const [indicatorActive, setIndicatorActive] = useState(false);
useLayoutEffect(() => {
if (!compact) return;
mobileSwipeReplyBridgeRef.current = {
room,
editor,
setReplyDraft,
layerEl: layerRef.current,
setIndicator: (top, active) => {
setIndicatorTop(top);
setIndicatorActive(active);
},
};
return () => {
if (mobileSwipeReplyBridgeRef.current?.room.roomId === room.roomId) {
mobileSwipeReplyBridgeRef.current = null;
}
setIndicatorTop(null);
setIndicatorActive(false);
};
}, [compact, editor, room, setReplyDraft]);
if (!compact) {
return <>{children}</>;
}
return (
<div ref={layerRef} className={css.SwipeToReplyLayer}>
{children}
{indicatorTop !== null && (
<div
className={`${css.SwipeToReplyIndicator} ${
indicatorActive ? css.SwipeToReplyIndicatorActive : ''
}`}
style={{ top: indicatorTop }}
aria-hidden
>
<Icon src={Icons.ReplyArrow} size="200" />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { globalStyle, style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
export const SwipeBackRoot = style({
position: 'relative',
flex: 1,
minWidth: 0,
minHeight: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
});
export const SwipeBackContent = style({
position: 'relative',
flex: 1,
minWidth: 0,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
backgroundColor: color.Background.Container,
boxShadow: '-4px 0 16px rgba(0, 0, 0, 0.28)',
willChange: 'transform',
touchAction: 'pan-y',
});
export const CompactDetailRoot = style({
position: 'relative',
minWidth: 0,
overflow: 'hidden',
});
export const CompactNavUnderlay = style({
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
zIndex: 0,
// Keep underlay tappable if the detail layer is empty/transparent
// (e.g. space index after leaving DMs). Opaque detail content still
// sits above and receives taps first.
pointerEvents: 'auto',
});
export const CompactDetailLayer = style({
position: 'relative',
zIndex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
// Pass through taps when outlet content is null/empty; real pages
// re-enable pointer events on their root via the child rule below.
pointerEvents: 'none',
});
globalStyle(`${CompactDetailLayer} > *`, {
pointerEvents: 'auto',
flex: 1,
minHeight: 0,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
backgroundColor: color.Background.Container,
});
export const SwipeToReplyLayer = style({
position: 'relative',
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
});
export const SwipeToReplyIndicator = style({
position: 'absolute',
right: config.space.S300,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: toRem(36),
height: toRem(36),
borderRadius: config.radii.Pill,
backgroundColor: color.Primary.Container,
color: color.Primary.OnContainer,
pointerEvents: 'none',
zIndex: 2,
opacity: 0,
transform: 'scale(0.85)',
transition: 'opacity 0.12s ease, transform 0.12s ease',
});
export const SwipeToReplyIndicatorActive = style({
opacity: 1,
transform: 'scale(1)',
});

View File

@@ -0,0 +1,15 @@
import { Room } from 'matrix-js-sdk';
import { Editor } from 'slate';
import { IReplyDraft } from '../../state/room/roomInputDrafts';
export type MobileSwipeReplyBridge = {
room: Room;
editor: Editor;
setReplyDraft: (draft: IReplyDraft | undefined) => void;
layerEl: HTMLElement | null;
setIndicator: (top: number | null, active: boolean) => void;
};
export const mobileSwipeReplyBridgeRef: { current: MobileSwipeReplyBridge | null } = {
current: null,
};

View File

@@ -0,0 +1,206 @@
import React, { ComponentProps, MutableRefObject, ReactNode } from 'react';
import { Box, Header, Line, Scroll, Text, as } from 'folds';
import classNames from 'classnames';
import { ContainerColor } from '../../styles/ContainerColor.css';
import * as css from './style.css';
import * as swipeCss from '../mobile/mobile-gestures.css';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
import { useShowCompactMasterView } from '../../hooks/useCompactNav';
type PageRootProps = {
nav: ReactNode;
children: ReactNode;
};
export function PageRoot({ nav, children }: PageRootProps) {
const screenSize = useScreenSizeContext();
const showCompactMaster = useShowCompactMasterView();
const compact = screenSize === ScreenSize.Mobile;
// Master list routes: only the channel/space list is interactive.
// Never mount the detail/underlay stack here — an empty detail layer would
// sit above the list with pointer-events and freeze taps (DM → space bug).
if (compact && showCompactMaster) {
return (
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
{nav}
</Box>
);
}
if (compact && !showCompactMaster) {
return (
<Box
grow="Yes"
className={classNames(ContainerColor({ variant: 'Background' }), swipeCss.CompactDetailRoot)}
>
{nav && (
<Box className={swipeCss.CompactNavUnderlay} aria-hidden>
{nav}
</Box>
)}
<Box grow="Yes" className={swipeCss.CompactDetailLayer}>
{children}
</Box>
</Box>
);
}
return (
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
{nav}
{screenSize !== ScreenSize.Mobile && (
<Line variant="Background" size="300" direction="Vertical" />
)}
{children}
</Box>
);
}
type ClientDrawerLayoutProps = {
children: ReactNode;
};
export function PageNav({
size,
children,
className,
}: ClientDrawerLayoutProps & css.PageNavVariants & { className?: string }) {
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
return (
<Box
grow={isMobile ? 'Yes' : undefined}
className={classNames(css.PageNav({ size }), className)}
shrink={isMobile ? 'Yes' : 'No'}
>
<Box grow="Yes" direction="Column">
{children}
<SidebarDockedCallPanel />
</Box>
</Box>
);
}
export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
({ className, outlined, ...props }, ref) => (
<Header
className={classNames(css.PageNavHeader({ outlined }), className)}
variant="Background"
size="600"
{...props}
ref={ref}
/>
)
);
export function PageNavContent({
scrollRef,
scrollProps,
children,
}: {
children: ReactNode;
scrollRef?: MutableRefObject<HTMLDivElement | null>;
scrollProps?: React.ComponentProps<typeof Scroll>;
}) {
return (
<Box grow="Yes" direction="Column">
<Scroll
ref={scrollRef}
variant="Background"
direction="Vertical"
size="300"
hideTrack
visibility="Hover"
{...scrollProps}
>
<div className={css.PageNavContent}>{children}</div>
</Scroll>
</Box>
);
}
export const Page = as<'div'>(({ className, ...props }, ref) => (
<Box
grow="Yes"
direction="Column"
className={classNames(ContainerColor({ variant: 'Surface' }), className)}
{...props}
ref={ref}
/>
));
export const PageHeader = as<'div', css.PageHeaderVariants>(
({ className, outlined, balance, ...props }, ref) => (
<Header
as="header"
size="600"
className={classNames(css.PageHeader({ balance, outlined }), className)}
{...props}
ref={ref}
/>
)
);
export const PageContent = as<'div'>(({ className, ...props }, ref) => (
<div className={classNames(css.PageContent, className)} {...props} ref={ref} />
));
export function PageHeroEmpty({ children }: { children: ReactNode }) {
return (
<Box
className={classNames(ContainerColor({ variant: 'SurfaceVariant' }), css.PageHeroEmpty)}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="200"
>
{children}
</Box>
);
}
export const PageHeroSection = as<'div', ComponentProps<typeof Box>>(
({ className, ...props }, ref) => (
<Box
direction="Column"
className={classNames(css.PageHeroSection, className)}
{...props}
ref={ref}
/>
)
);
export function PageHero({
icon,
title,
subTitle,
children,
}: {
icon: ReactNode;
title: ReactNode;
subTitle: ReactNode;
children?: ReactNode;
}) {
return (
<Box direction="Column" gap="400">
<Box direction="Column" alignItems="Center" gap="200">
{icon}
</Box>
<Box as="h2" direction="Column" gap="200" alignItems="Center">
<Text align="Center" size="H2">
{title}
</Text>
<Text align="Center" priority="400">
{subTitle}
</Text>
</Box>
{children}
</Box>
);
}
export const PageContentCenter = as<'div'>(({ className, ...props }, ref) => (
<div className={classNames(css.PageContentCenter, className)} {...props} ref={ref} />
));

View File

@@ -280,7 +280,7 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
escapeDeactivates: stopPropagation,
}}
>
<Modal size="300" style={{ maxWidth: '280px', maxHeight: 'fit-content' }}>
<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>

View File

@@ -0,0 +1,136 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Scroll, Text, color } from 'folds';
import { Icon, Icons } from '../../components/icons';
import classNames from 'classnames';
import { useSetAtom } from 'jotai';
import { useNavigate } from 'react-router-dom';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useCompactNav } from '../../hooks/useCompactNav';
import { Page, PageContent } from '../../components/page';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { activeThreadIdAtomFamily } from '../../state/activeThread';
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
import { getSpacePath } from '../../pages/pathUtils';
import { useForumBoardContext } from './ForumBoardContext';
import { ForumThreadDetail } from './ForumThreadDetail';
import type { ForumPost } from './types';
import * as theme from './forumTheme.css';
/** Thread detail in the main pane; post list lives in ForumFeedSidebar. */
export function ForumBoardDetail() {
const mx = useMatrixClient();
const navigate = useNavigate();
const compact = useCompactNav();
const [optimisticPost, setOptimisticPost] = useState<ForumPost | null>(null);
const {
forumSpace,
query,
error,
selectedPostId,
activeTopicRoomId,
threads,
recordThreadReply,
} = useForumBoardContext();
// Compact: /feed/ without a selected post has no detail content — return to the list
// so an empty detail layer cannot cover the forum sidebar.
useEffect(() => {
if (!compact || selectedPostId) return;
navigate(getSpacePath(getCanonicalAliasOrRoomId(mx, forumSpace.roomId)), { replace: true });
}, [compact, selectedPostId, navigate, mx, forumSpace.roomId]);
const activeTopicRoom = activeTopicRoomId ? mx.getRoom(activeTopicRoomId) : null;
const topicSelected = Boolean(query.topic);
const setActiveThreadId = useSetAtom(
activeThreadIdAtomFamily(activeTopicRoomId ?? forumSpace.roomId)
);
useEffect(() => {
if (selectedPostId && activeTopicRoomId) {
setActiveThreadId(selectedPostId);
return () => setActiveThreadId(undefined);
}
setActiveThreadId(undefined);
return undefined;
}, [selectedPostId, activeTopicRoomId, setActiveThreadId]);
useEffect(() => {
if (optimisticPost && optimisticPost.eventId !== selectedPostId) {
setOptimisticPost(null);
}
}, [optimisticPost, selectedPostId]);
const selectedThread = useMemo(
() => threads.find((t) => t.eventId === selectedPostId),
[threads, selectedPostId]
);
const handleReplySent = useCallback(
(_reply: ForumPost) => {
if (selectedPostId) {
recordThreadReply(selectedPostId);
}
},
[recordThreadReply, selectedPostId]
);
const showThread = Boolean(selectedPostId && activeTopicRoom);
const threadInitialPost =
optimisticPost?.eventId === selectedPostId ? optimisticPost : null;
const detailDropRef = useRef<HTMLDivElement>(null);
return (
<Page className={classNames(ContainerColor({ variant: 'Background' }), theme.ForumAppRoot)}>
<PageContent className={theme.ForumPageContent}>
{error && (
<Text as="p" style={{ color: color.Critical.Main, padding: '0.5rem 1rem' }}>
{error}
</Text>
)}
<aside className={theme.ForumDetailPane}>
{showThread && activeTopicRoom ? (
<div ref={detailDropRef} className={theme.ForumTopicPostDetailThreadLayout}>
<ForumThreadDetail
roomId={activeTopicRoom.roomId}
rootEventId={selectedPostId!}
titleHint={selectedThread?.title}
initialPost={threadInitialPost}
fileDropContainerRef={detailDropRef}
onReplySent={handleReplySent}
/>
</div>
) : (
<Scroll
id="topicPostDetail"
className={theme.ForumTopicPostDetailScroll}
variant="Surface"
direction="Vertical"
size="300"
hideTrack
visibility="Hover"
>
<div className={theme.ForumTopicPostDetailInner}>
<Box
className={theme.ForumEmptyDetail}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="300"
>
<Icon src={Icons.Thread} size="600" />
<Text size="T300" priority="300">
{topicSelected
? 'Select a post to read the thread'
: 'Pick a category and topic, then choose a post'}
</Text>
</Box>
</div>
</Scroll>
)}
</aside>
</PageContent>
</Page>
);
}

View File

@@ -0,0 +1,431 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useCompactNav } from '../../hooks/useCompactNav';
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
import { getSpaceFeedPath, getSpaceLobbyPath, getSpacePath } from '../../pages/pathUtils';
import { createRoomModalAtom } from '../../state/createRoomModal';
import { createSpaceModalAtom } from '../../state/createSpaceModal';
import { StateEvent } from '../../../types/matrix/room';
import {
buildForumSections,
buildTopicThreads,
buildThreadSummaries,
} from './forumTopicHelpers';
import { applyTopicStatusSort } from './topicSearch';
import {
createForumPostFromPayload,
listForumTopics,
listTopicFeedPosts,
loadAggregatedTopicFeed,
} from './forumFeed';
import type {
ForumBoardQuery,
ForumPost,
ForumPublishedPost,
ForumSection,
ForumThreadSummary,
} from './types';
import { useForumRoomLiveUpdates } from './useForumRoomLiveUpdates';
export type ForumBoardScope = {
forumSpaceId: string;
/** When set, only show posts from this topic room. */
topicRoomId?: string;
};
function scopesFromSelection(
sections: ForumSection[],
category?: string,
topic?: string
): Array<{ roomId: string; sectionTitle?: string; topicName?: string }> {
if (topic) {
for (const section of sections) {
const match = section.topics.find((t) => t.roomId === topic);
if (match) {
return [{ roomId: match.roomId, sectionTitle: section.title, topicName: match.name }];
}
}
return [{ roomId: topic }];
}
if (category) {
const section = sections.find((s) => s.title === category);
if (!section) return [];
return section.topics.map((t) => ({
roomId: t.roomId,
sectionTitle: section.title,
topicName: t.name,
}));
}
return sections.flatMap((section) =>
section.topics.map((t) => ({
roomId: t.roomId,
sectionTitle: section.title,
topicName: t.name,
}))
);
}
export function useForumBoard(scope: ForumBoardScope, forumSpace: Room) {
const mx = useMatrixClient();
const location = useLocation();
const navigate = useNavigate();
const selectedRoomId = useSelectedRoom();
const compact = useCompactNav();
const searchParams = useMemo(
() => new URLSearchParams(location.search),
[location.search]
);
const filterQuery: ForumBoardQuery = useMemo(
() => ({
category: searchParams.get('category') || undefined,
topic: searchParams.get('topic') || undefined,
q: searchParams.get('q') || undefined,
status: (searchParams.get('status') as ForumBoardQuery['status']) || 'all',
sort: (searchParams.get('sort') as ForumBoardQuery['sort']) || 'hot',
}),
[searchParams]
);
const query: ForumBoardQuery = useMemo(
() => ({
...filterQuery,
post: searchParams.get('post') || undefined,
postRoom: searchParams.get('postRoom') || undefined,
}),
[filterQuery, searchParams]
);
const [sections, setSections] = useState<ForumSection[]>([]);
const [threads, setThreads] = useState<ForumThreadSummary[]>([]);
const [loadingSections, setLoadingSections] = useState(true);
const [loadingThreads, setLoadingThreads] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [postsNextBatch, setPostsNextBatch] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const applyForumSearchParams = useCallback(
(mutate: (next: URLSearchParams) => void) => {
const next = new URLSearchParams(location.search);
mutate(next);
const search = next.toString();
const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, forumSpace.roomId);
const onTopicRoomRoute =
Boolean(selectedRoomId) && selectedRoomId !== forumSpace.roomId;
// Compact master view only mounts the post list on /:spaceId/. Opening a post
// must switch to /feed/ so ForumFeedPage mounts as the detail pane.
let pathname = location.pathname;
if (onTopicRoomRoute) {
pathname = getSpaceLobbyPath(spaceIdOrAlias);
} else if (next.get('post')) {
pathname = getSpaceFeedPath(spaceIdOrAlias);
} else if (compact) {
pathname = getSpacePath(spaceIdOrAlias);
}
navigate(
{
pathname,
search: search ? `?${search}` : '',
},
{ replace: true }
);
},
[
location.pathname,
location.search,
navigate,
mx,
forumSpace.roomId,
selectedRoomId,
compact,
]
);
const setQueryParam = useCallback(
(key: string, value: string | null) => {
applyForumSearchParams((next) => {
if (!value) next.delete(key);
else next.set(key, value);
if (key !== 'post') {
next.delete('post');
next.delete('postRoom');
}
});
},
[applyForumSearchParams]
);
const setCategory = useCallback(
(category: string | null) => {
applyForumSearchParams((next) => {
if (!category) next.delete('category');
else next.set('category', category);
next.delete('topic');
next.delete('post');
next.delete('postRoom');
});
},
[applyForumSearchParams]
);
const loadSections = useCallback(async () => {
setLoadingSections(true);
setError(null);
try {
const topics = await listForumTopics(mx, scope.forumSpaceId);
setSections(buildForumSections(topics));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load forum categories');
} finally {
setLoadingSections(false);
}
}, [mx, scope.forumSpaceId]);
const refreshThreads = useCallback(async (options?: { silent?: boolean }) => {
if (!options?.silent) setLoadingThreads(true);
setError(null);
try {
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
if (roomScopes.length === 0) {
setThreads([]);
return;
}
let posts;
if (roomScopes.length === 1) {
const result = await listTopicFeedPosts(mx, roomScopes[0].roomId);
posts = result.posts;
setPostsNextBatch(result.nextBatch);
const scopeMeta = roomScopes[0];
posts.forEach((post) => {
post.topicRoomId = scopeMeta.roomId;
if (scopeMeta.sectionTitle) post.sectionTitle = scopeMeta.sectionTitle;
if (scopeMeta.topicName) post.topicName = scopeMeta.topicName;
});
} else {
posts = await loadAggregatedTopicFeed(mx, roomScopes);
setPostsNextBatch(null);
}
setThreads(buildTopicThreads(posts, filterQuery));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load forum posts');
} finally {
if (!options?.silent) setLoadingThreads(false);
}
}, [mx, sections, filterQuery]);
useEffect(() => {
loadSections();
}, [loadSections]);
useEffect(() => {
const handleSpaceChild = (event: MatrixEvent) => {
if (event.getType() !== StateEvent.SpaceChild) return;
const roomId = event.getRoomId();
if (!roomId) return;
if (
roomId === forumSpace.roomId ||
sections.some((section) => section.spaceId === roomId)
) {
loadSections();
}
};
mx.on(RoomStateEvent.Events, handleSpaceChild);
return () => {
mx.removeListener(RoomStateEvent.Events, handleSpaceChild);
};
}, [mx, forumSpace.roomId, sections, loadSections]);
const createSpaceModal = useAtomValue(createSpaceModalAtom);
const createRoomModal = useAtomValue(createRoomModalAtom);
const prevCreateSpaceModal = useRef(createSpaceModal);
const prevCreateRoomModal = useRef(createRoomModal);
useEffect(() => {
if (prevCreateSpaceModal.current && !createSpaceModal) {
loadSections();
}
if (prevCreateRoomModal.current && !createRoomModal) {
loadSections();
}
prevCreateSpaceModal.current = createSpaceModal;
prevCreateRoomModal.current = createRoomModal;
}, [createSpaceModal, createRoomModal, loadSections]);
useEffect(() => {
if (loadingSections) return;
void refreshThreads();
}, [
loadingSections,
refreshThreads,
filterQuery.category,
filterQuery.topic,
filterQuery.q,
filterQuery.status,
filterQuery.sort,
]);
const scopedRooms = useMemo(() => {
if (loadingSections) return [];
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
return roomScopes
.map((scope) => mx.getRoom(scope.roomId))
.filter((room): room is Room => room !== null);
}, [loadingSections, sections, filterQuery.category, filterQuery.topic, mx]);
useForumRoomLiveUpdates(scopedRooms, () => {
void refreshThreads({ silent: true });
});
const selectedPostId = query.post ?? null;
const selectedThread = threads.find((t) => t.eventId === selectedPostId);
const activeTopicRoomId =
query.postRoom || selectedThread?.topicRoomId || query.topic || scope.topicRoomId || null;
const selectPost = useCallback(
(eventId: string | null, topicRoomId?: string) => {
applyForumSearchParams((next) => {
if (!eventId) {
next.delete('post');
next.delete('postRoom');
} else {
next.set('post', eventId);
if (topicRoomId) next.set('postRoom', topicRoomId);
else next.delete('postRoom');
}
});
},
[applyForumSearchParams]
);
const insertThreadFromNewPost = useCallback(
(published: ForumPublishedPost): ForumPost | null => {
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
const inScope =
filterQuery.topic === published.topicRoomId ||
(!filterQuery.topic &&
(filterQuery.category
? roomScopes.some((s) => s.roomId === published.topicRoomId)
: true));
const scopeMeta = roomScopes.find((s) => s.roomId === published.topicRoomId);
const post = createForumPostFromPayload(
mx,
published.topicRoomId,
published.eventId,
{
title: published.title,
plainText: published.plainText,
formattedHtml: published.formattedHtml,
},
{
topicRoomId: published.topicRoomId,
sectionTitle: scopeMeta?.sectionTitle,
topicName: scopeMeta?.topicName,
}
);
if (inScope) {
const [summary] = buildThreadSummaries([post]);
if (summary) {
setThreads((prev) => {
const without = prev.filter((t) => t.eventId !== summary.eventId);
const merged = [summary, ...without];
return applyTopicStatusSort(merged, filterQuery.status || 'all', filterQuery.sort || 'hot');
});
}
}
return post;
},
[mx, sections, filterQuery]
);
const recordThreadReply = useCallback((rootEventId: string) => {
setThreads((prev) =>
prev.map((t) =>
t.eventId === rootEventId
? {
...t,
totalReplies: t.totalReplies + 1,
lastActivityTs: Date.now(),
}
: t
)
);
}, []);
const loadMorePosts = useCallback(async () => {
if (!postsNextBatch || loadingMore) return;
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
if (roomScopes.length !== 1) return;
setLoadingMore(true);
setError(null);
try {
const scopeMeta = roomScopes[0];
const result = await listTopicFeedPosts(mx, scopeMeta.roomId, {
from: postsNextBatch,
minRoots: 1,
});
setPostsNextBatch(result.nextBatch);
result.posts.forEach((post) => {
post.topicRoomId = scopeMeta.roomId;
if (scopeMeta.sectionTitle) post.sectionTitle = scopeMeta.sectionTitle;
if (scopeMeta.topicName) post.topicName = scopeMeta.topicName;
});
setThreads((prev) => {
const existingIds = new Set(prev.map((t) => t.eventId));
const added = buildTopicThreads(result.posts, filterQuery).filter(
(t) => !existingIds.has(t.eventId)
);
const merged = [...prev, ...added];
const sort = filterQuery.sort || 'hot';
merged.sort((a, b) => {
if (sort === 'new') return b.timestamp - a.timestamp;
if (sort === 'top') return b.totalReplies - a.totalReplies;
const hotA = a.totalReplies * 4 + Math.floor((a.lastActivityTs - a.timestamp) / 3600000);
const hotB = b.totalReplies * 4 + Math.floor((b.lastActivityTs - b.timestamp) / 3600000);
return hotB - hotA;
});
return merged;
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load older posts');
} finally {
setLoadingMore(false);
}
}, [postsNextBatch, loadingMore, sections, filterQuery, mx]);
return {
forumSpace,
sections,
threads,
query,
loadingSections,
loadingThreads,
error,
selectedPostId,
selectedThread,
activeTopicRoomId,
setQueryParam,
setCategory,
selectPost,
refreshThreads,
insertThreadFromNewPost,
recordThreadReply,
loadMorePosts,
hasMorePosts: Boolean(postsNextBatch),
loadingMore,
};
}

View File

@@ -0,0 +1,185 @@
import React, { useCallback, useRef } from 'react';
import { Box, Text, config } from 'folds';
import { EventType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
import { isKeyHotkey } from 'is-hotkey';
import { useAtomValue } from 'jotai';
import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useEditor } from '../../components/editor';
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
import { RoomTimeline } from './RoomTimeline';
import { RoomViewTyping } from './RoomViewTyping';
import { RoomTombstone } from './RoomTombstone';
import { RoomInput } from './RoomInput';
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
import { Page } from '../../components/page';
import { RoomViewHeader } from './RoomViewHeader';
import { useKeyDown } from '../../hooks/useKeyDown';
import { editableActiveElement } from '../../utils/dom';
import { settingsAtom } from '../../state/settings';
import { useSetting } from '../../state/hooks/settings';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { activeThreadIdAtomFamily } from '../../state/activeThread';
import { ThreadView } from './ThreadView';
import { MobileSwipeToReplyLayer } from '../../components/mobile/MobileSwipeToReplyLayer';
import { useMobileKeyboardLayout } from '../../hooks/useMobileKeyboardLayout';
import * as composerCss from './room-composer-mobile.css';
const FN_KEYS_REGEX = /^F\d+$/;
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
const { code } = evt;
if (evt.metaKey || evt.altKey || evt.ctrlKey) {
return false;
}
if (FN_KEYS_REGEX.test(code)) return false;
if (
code.startsWith('OS') ||
code.startsWith('Meta') ||
code.startsWith('Shift') ||
code.startsWith('Alt') ||
code.startsWith('Control') ||
code.startsWith('Arrow') ||
code.startsWith('Page') ||
code.startsWith('End') ||
code.startsWith('Home') ||
code === 'Tab' ||
code === 'Space' ||
code === 'Enter' ||
code === 'NumLock' ||
code === 'ScrollLock'
) {
return false;
}
return true;
};
export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
const roomInputRef = useRef<HTMLDivElement>(null);
const roomViewRef = useRef<HTMLDivElement>(null);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const { immersive } = useMobileKeyboardLayout();
const { roomId } = room;
const editor = useEditor();
const mx = useMatrixClient();
const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());
const activeThreadId = useAtomValue(activeThreadIdAtomFamily(roomId));
useKeyDown(
window,
useCallback(
(evt) => {
if (editableActiveElement()) return;
const portalContainer = document.getElementById('portalContainer');
if (portalContainer && portalContainer.children.length > 0) {
return;
}
if (shouldFocusMessageField(evt)) {
evt.preventDefault();
ReactEditor.focus(editor);
if (evt.key.length === 1) {
editor.insertText(evt.key);
}
} else if (isKeyHotkey('mod+v', evt)) {
ReactEditor.focus(editor);
}
},
[editor]
)
);
const flushComposer = immersive;
const composerPadding = flushComposer ? '0' : `0 ${config.space.S400}`;
const showHeader = !immersive;
return (
<Page ref={roomViewRef}>
{showHeader && <RoomViewHeader />}
{activeThreadId ? (
<ThreadView room={room} threadRootId={activeThreadId} />
) : (
<>
<MobileSwipeToReplyLayer room={room} editor={editor}>
<Box grow="Yes" direction="Column">
<RoomTimeline
key={roomId}
room={room}
eventId={eventId}
roomInputRef={roomInputRef}
editor={editor}
/>
<RoomViewTyping room={room} />
</Box>
</MobileSwipeToReplyLayer>
<Box
shrink="No"
direction="Column"
data-disable-swipe-back="true"
data-composer-flush={flushComposer ? 'true' : undefined}
className={composerCss.ComposerDock}
>
{immersive && !hideActivity && (
<div className={composerCss.FollowingFloat}>
<div className={composerCss.FollowingFloatHit}>
<RoomViewFollowing room={room} />
</div>
</div>
)}
<div style={{ padding: composerPadding }}>
{tombstoneEvent ? (
<RoomTombstone
roomId={roomId}
body={tombstoneEvent.getContent().body}
replacementRoomId={tombstoneEvent.getContent().replacement_room}
/>
) : (
<>
{canMessage && (
<RoomInput
room={room}
editor={editor}
roomId={roomId}
fileDropContainerRef={roomViewRef}
ref={roomInputRef}
/>
)}
{!canMessage && (
<RoomInputPlaceholder
style={{ padding: config.space.S200 }}
alignItems="Center"
justifyContent="Center"
>
<Text align="Center">You do not have permission to post in this room</Text>
</RoomInputPlaceholder>
)}
</>
)}
</div>
{!immersive &&
(hideActivity ? (
<RoomViewFollowingPlaceholder />
) : (
<RoomViewFollowing room={room} />
))}
</Box>
</>
)}
</Page>
);
}

View File

@@ -0,0 +1,49 @@
import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds';
export const RoomViewFollowingPlaceholder = style([
DefaultReset,
{
height: toRem(28),
},
]);
export const RoomViewFollowing = recipe({
base: [
DefaultReset,
{
minHeight: toRem(28),
padding: `0 ${config.space.S400}`,
width: '100%',
backgroundColor: color.Surface.Container,
color: color.Surface.OnContainer,
outline: 'none',
selectors: {
// Compact floating chip while the keyboard is open.
'[data-composer-flush="true"] &': {
minHeight: 0,
width: 'auto',
maxWidth: '100%',
padding: `${config.space.S100} ${config.space.S200}`,
backgroundColor: 'transparent',
},
},
},
],
variants: {
clickable: {
true: {
cursor: 'pointer',
selectors: {
'&:hover, &:focus-visible': {
color: color.Primary.Main,
},
'&:active': {
color: color.Primary.Main,
},
},
},
},
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
import { RelationType, Room } from 'matrix-js-sdk';
import { Editor } from 'slate';
import { ReactEditor } from 'slate-react';
import { getEditedEvent } from '../../utils/room';
import { IReplyDraft } from '../../state/room/roomInputDrafts';
export const startReplyToEvent = (
room: Room,
eventId: string,
setReplyDraft: (draft: IReplyDraft | undefined) => void,
editor?: Editor,
startThread = false
): boolean => {
const replyEvt = room.findEventById(eventId);
if (!replyEvt || replyEvt.isRedacted()) return false;
const editedReply = getEditedEvent(eventId, replyEvt, room.getUnfilteredTimelineSet());
const content = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
const { body, formatted_body: formattedBody } = content;
const senderId = replyEvt.getSender();
if (!senderId || typeof body !== 'string') return false;
const relation = startThread
? { rel_type: RelationType.Thread, event_id: eventId }
: replyEvt.getWireContent()['m.relates_to'];
setReplyDraft({
userId: senderId,
eventId,
body,
formattedBody,
relation,
});
if (editor) {
setTimeout(() => ReactEditor.focus(editor), 100);
}
return true;
};

View File

@@ -0,0 +1,63 @@
import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';
import { DefaultReset, color, config } from 'folds';
export const ComposerDock = style({
position: 'relative',
});
/** Read receipts float above the input so they don't reserve layout height. */
export const FollowingFloat = style({
position: 'absolute',
bottom: '100%',
left: 0,
right: 0,
zIndex: 2,
display: 'flex',
justifyContent: 'flex-end',
pointerEvents: 'none',
padding: `0 ${config.space.S200}`,
});
export const FollowingFloatHit = style({
pointerEvents: 'auto',
maxWidth: '100%',
});
export const RoomViewFollowingPlaceholder = style([
DefaultReset,
{
display: 'none',
height: 0,
},
]);
export const RoomViewFollowing = recipe({
base: [
DefaultReset,
{
minHeight: 0,
padding: `${config.space.S100} ${config.space.S200}`,
width: 'auto',
maxWidth: '100%',
backgroundColor: 'transparent',
color: color.Surface.OnContainer,
outline: 'none',
},
],
variants: {
clickable: {
true: {
cursor: 'pointer',
selectors: {
'&:hover, &:focus-visible': {
color: color.Primary.Main,
},
'&:active': {
color: color.Primary.Main,
},
},
},
},
},
});

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,30 @@
import { matchPath, useLocation } from 'react-router-dom';
import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from '../pages/paths';
import { ScreenSize, useScreenSizeContext } from './useScreenSize';
/** Skinny window layout (≤750px): master-detail instead of 3-column desktop. */
export const useCompactNav = (): boolean => {
const screenSize = useScreenSizeContext();
return screenSize === ScreenSize.Mobile;
};
const LIST_ROUTE_PATHS = [HOME_PATH, DIRECT_PATH, SPACE_PATH, EXPLORE_PATH, INBOX_PATH] as const;
/**
* Routes where sidebar + channel list are shown together (no room/content pane).
* Uses matchPath on the current pathname so trailing-slash / encoding quirks
* after DM → space navigations still count as master list routes.
*/
export const useIsCompactListRoute = (): boolean => {
const { pathname } = useLocation();
return LIST_ROUTE_PATHS.some(
(path) => matchPath({ path, caseSensitive: true, end: true }, pathname) != null
);
};
export const useShowCompactMasterView = (): boolean => {
const compact = useCompactNav();
const isListRoute = useIsCompactListRoute();
return compact && isListRoute;
};

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

@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { isCapacitorNative } from '../utils/tauri';
const KEYBOARD_HEIGHT_THRESHOLD_PX = 120;
const IMMERSIVE_CLASS = 'immersive-landscape-keyboard';
const getIsLandscape = (): boolean => {
if (typeof window === 'undefined') return false;
if (window.matchMedia?.('(orientation: landscape)').matches) return true;
return window.innerWidth > window.innerHeight;
};
const getKeyboardOpen = (): boolean => {
if (typeof window === 'undefined') return false;
const viewport = window.visualViewport;
if (viewport) {
const obscured = window.innerHeight - viewport.height - viewport.offsetTop;
if (obscured > KEYBOARD_HEIGHT_THRESHOLD_PX) return true;
}
const active = document.activeElement;
if (!(active instanceof HTMLElement)) return false;
if (active.isContentEditable) return true;
const tag = active.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA';
};
const setNativeStatusBarHidden = async (hidden: boolean) => {
if (!isCapacitorNative()) return;
try {
const { StatusBar } = await import('@capacitor/status-bar');
if (hidden) {
await StatusBar.hide();
} else {
await StatusBar.show();
}
} catch {
// Plugin may be missing in some builds; CSS safe-area collapse still applies.
}
};
export type MobileKeyboardLayout = {
isLandscape: boolean;
keyboardOpen: boolean;
/** Landscape + keyboard: compact immersive room chrome. */
immersive: boolean;
};
/**
* Landscape + soft-keyboard layout signals for compact room chrome.
* Uses visualViewport when available (Capacitor/Android WebView).
*/
export const useMobileKeyboardLayout = (): MobileKeyboardLayout => {
const [isLandscape, setIsLandscape] = useState(getIsLandscape);
const [keyboardOpen, setKeyboardOpen] = useState(getKeyboardOpen);
const immersive = isLandscape && keyboardOpen;
useEffect(() => {
const sync = () => {
setIsLandscape(getIsLandscape());
setKeyboardOpen(getKeyboardOpen());
};
sync();
const orientationQuery = window.matchMedia?.('(orientation: landscape)');
orientationQuery?.addEventListener?.('change', sync);
window.addEventListener('resize', sync);
window.visualViewport?.addEventListener('resize', sync);
window.visualViewport?.addEventListener('scroll', sync);
window.addEventListener('focusin', sync);
window.addEventListener('focusout', sync);
return () => {
orientationQuery?.removeEventListener?.('change', sync);
window.removeEventListener('resize', sync);
window.visualViewport?.removeEventListener('resize', sync);
window.visualViewport?.removeEventListener('scroll', sync);
window.removeEventListener('focusin', sync);
window.removeEventListener('focusout', sync);
};
}, []);
useEffect(() => {
document.documentElement.classList.toggle(IMMERSIVE_CLASS, immersive);
void setNativeStatusBarHidden(immersive);
return () => {
document.documentElement.classList.remove(IMMERSIVE_CLASS);
void setNativeStatusBarHidden(false);
};
}, [immersive]);
return { isLandscape, keyboardOpen, immersive };
};

View File

@@ -0,0 +1,57 @@
import { useCallback, useEffect } from 'react';
import { useLocation, useNavigate, NavigateOptions } from 'react-router-dom';
import { supportsViewTransitions, withViewTransition } from '../utils/viewTransitions';
export const useViewTransitions = () => {
const location = useLocation();
useEffect(() => {
if (!supportsViewTransitions()) {
return;
}
document.documentElement.classList.add('view-transitions-enabled');
return () => {
document.documentElement.classList.remove('view-transitions-enabled');
};
}, []);
useEffect(() => {
// Route changed
}, [location]);
return {
supportsViewTransitions: supportsViewTransitions(),
};
};
/**
* Navigate helper. On Capacitor / coarse-pointer, View Transitions are disabled
* so DM ↔ space switches cannot leave a stuck hit-blocking overlay.
*/
export const useNavigateWithTransition = () => {
const navigate = useNavigate();
return useCallback(
(to: string | number, options?: NavigateOptions) => {
if (!supportsViewTransitions()) {
if (typeof to === 'number') {
navigate(to);
} else {
navigate(to, options);
}
return;
}
withViewTransition(() => {
if (typeof to === 'number') {
navigate(to);
} else {
navigate(to, options);
}
});
},
[navigate]
);
};

View File

@@ -0,0 +1,20 @@
import { ReactNode } from 'react';
type MobileFriendlyClientNavProps = {
children: ReactNode;
};
/** Server sidebar stays mounted on compact detail routes for swipe-back reveal. */
export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavProps) {
return children;
}
type MobileFriendlyPageNavProps = {
path: string;
children: ReactNode;
};
/** Channel list stays mounted on compact detail routes for swipe-back reveal. */
export function MobileFriendlyPageNav({ path: _path, children }: MobileFriendlyPageNavProps) {
return children;
}

View File

@@ -0,0 +1,21 @@
import React, { ReactNode } from 'react';
import { Box } from 'folds';
import { DockedCallPanel } from '../../features/call/DockedCallPanel';
import { useMobileKeyboardLayout } from '../../hooks/useMobileKeyboardLayout';
type ClientLayoutProps = {
nav: ReactNode;
children: ReactNode;
};
export function ClientLayout({ nav, children }: ClientLayoutProps) {
const { immersive } = useMobileKeyboardLayout();
return (
<Box grow="Yes">
{!immersive && <Box shrink="No">{nav}</Box>}
<Box grow="Yes">{children}</Box>
<DockedCallPanel />
</Box>
);
}

View File

@@ -1,5 +1,4 @@
import { useAtomValue } from 'jotai';
import { useAtom, useSetAtom } from 'jotai';
import { useAtomValue, useStore } from 'jotai';
import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
import { useNavigate } from 'react-router-dom';
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
@@ -23,13 +22,12 @@ import {
guessPerfectParent,
} from '../../utils/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 { roomToParentsAtom } from '../../state/room/roomToParents';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { plainToEditorInput, toPlainText } from '../../components/editor';
import { ShareRoomPicker } from '../../features/ShareRoomPicker';
import {
isTauri,
@@ -46,10 +44,10 @@ import {
} from '../../utils/backgroundSync';
import {
TUploadItem,
roomIdToMsgDraftAtomFamily,
roomIdToUploadItemsAtomFamily,
} from '../../state/room/roomInputDrafts';
import { encryptFile } from '../../utils/matrix';
import { fulfilledPromiseSettledResult } from '../../utils/common';
import { safeFile } from '../../utils/mimeTypes';
import {
AndroidSharePayload,
clearPendingAndroidShare,
@@ -517,10 +515,71 @@ function TaskbarFlashStopper() {
function AndroidShareIntentHandler() {
const mx = useMatrixClient();
const store = useStore();
const navigate = useNavigate();
const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null);
const mDirects = useAtomValue(mDirectAtom);
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(
async (share: AndroidSharePayload, roomId: string) => {
@@ -543,7 +602,7 @@ function AndroidShareIntentHandler() {
if (share.files.length > 0) {
for (const sharedFile of share.files) {
const originalFile = await materializeSharedFile(sharedFile, share.receivedAt);
let fileToUpload = originalFile;
let encInfo: any = undefined;
@@ -594,6 +653,22 @@ function AndroidShareIntentHandler() {
[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(
(roomId: string) => {
if (!pendingShare) return;
@@ -602,7 +677,7 @@ function AndroidShareIntentHandler() {
.then((applied) => {
if (applied) {
setPendingShare(null);
// Navigate to the selected room
const isDirect = mDirects.has(roomId);
if (isDirect) {
@@ -640,7 +715,7 @@ function AndroidShareIntentHandler() {
getPendingAndroidShare()
.then((share) => {
if (mounted && share) {
setPendingShare(share);
ingestShare(share);
}
})
.catch((err) => {
@@ -649,7 +724,7 @@ function AndroidShareIntentHandler() {
listenForAndroidShares((share) => {
if (mounted) {
setPendingShare(share);
ingestShare(share);
}
})
.then((handle) => {
@@ -663,7 +738,7 @@ function AndroidShareIntentHandler() {
mounted = false;
void listenerHandle?.remove();
};
}, []);
}, [ingestShare]);
if (!pendingShare) return null;

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,24 @@ 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: keep emoji fonts out of the body text stack and use system UI fonts.
*/
export const applyAndroidTypographyFix = (): void => {
if (!isCapacitorAndroid()) return;
const root = document.documentElement;
root.classList.add('android-capacitor');
root.style.setProperty('--font-secondary', 'system-ui, Roboto, "Noto Sans", sans-serif');
};
/**
* Apply safe area insets for mobile devices
* On Android, CSS env() may not work, so we apply fallback padding

View File

@@ -0,0 +1,81 @@
import { isCapacitorNative } from './tauri';
/**
* View Transitions can leave an invisible hit-blocking overlay on Android
* WebView after DM ↔ space navigations. Keep them off for native/touch shells.
*/
const shouldDisableViewTransitions = (): boolean => {
if (typeof window === 'undefined') return true;
if (isCapacitorNative()) return true;
if (window.matchMedia?.('(pointer: coarse)').matches) return true;
return false;
};
export const supportsViewTransitions = (): boolean => {
if (shouldDisableViewTransitions()) return false;
return 'startViewTransition' in document;
};
export const withViewTransition = async (callback: () => void | Promise<void>): Promise<void> => {
if (!supportsViewTransitions()) {
await callback();
return;
}
const doc = document as Document & {
startViewTransition: (callback: () => void | Promise<void>) => {
finished: Promise<void>;
updateCallbackDone: Promise<void>;
ready: Promise<void>;
};
};
try {
const transition = doc.startViewTransition(async () => {
await callback();
});
await transition.finished;
} catch {
await callback();
}
};
/**
* Global link interception for View Transitions. No-op on Capacitor / touch.
* Also fights hash-router navigations — leave disabled on mobile.
*/
export const enableViewTransitionsForNavigation = (): void => {
if (!supportsViewTransitions()) {
return;
}
const handleLinkClick = (event: MouseEvent) => {
const target = (event.target as HTMLElement).closest('a');
if (!target) return;
if (target.target === '_blank') return;
if (target.origin !== window.location.origin) return;
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
const url = new URL(target.href);
if (url.origin === window.location.origin) {
event.preventDefault();
withViewTransition(() => {
window.history.pushState({}, '', target.href);
window.dispatchEvent(new PopStateEvent('popstate'));
});
}
};
const handlePopState = () => {
withViewTransition(() => {
// URL already changed
});
};
window.addEventListener('click', handleLinkClick);
window.addEventListener('popstate', handlePopState);
};

18
overlay/src/font-setup.ts Normal file
View File

@@ -0,0 +1,18 @@
const cap =
typeof window !== 'undefined'
? (window as { Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string } }).Capacitor
: undefined;
export const isCapacitorAndroid = Boolean(
cap?.isNativePlatform?.() && cap?.getPlatform?.() === 'android'
);
if (isCapacitorAndroid) {
document.documentElement.classList.add('android-capacitor');
document.documentElement.style.setProperty(
'--font-secondary',
'system-ui, Roboto, "Noto Sans", sans-serif'
);
} else {
await import('@fontsource-variable/inter');
}

578
overlay/src/index.css Normal file
View File

@@ -0,0 +1,578 @@
/* Twemoji font (Twitter emoji) */
@font-face {
font-family: Twemoji;
src: url('../public/font/Twemoji.Mozilla.v15.1.0.woff2'),
url('../public/font/Twemoji.Mozilla.v15.1.0.ttf');
font-display: swap;
unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+FE0F, U+200D, U+1F1E6-1F1FF;
}
/* Apple Color Emoji - uses local Apple Color Emoji if available (macOS/iOS),
falls back to Segoe UI Emoji (Windows) or Noto (Linux/Android) */
@font-face {
font-family: AppleColorEmoji;
src: local('Apple Color Emoji'),
local('Segoe UI Emoji'),
local('Noto Color Emoji');
font-display: swap;
unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+FE0F, U+200D, U+1F1E6-1F1FF;
}
/* System emoji - uses native OS emoji */
@font-face {
font-family: SystemEmoji;
src: local('Apple Color Emoji'),
local('Segoe UI Emoji'),
local('Segoe UI Symbol'),
local('Noto Color Emoji'),
local('Android Emoji'),
local('EmojiOne Color');
font-display: swap;
unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+FE0F, U+200D, U+1F1E6-1F1FF;
}
:root {
--tc-link: hsl(213deg 100% 45%);
/* user mxid colors */
--mx-uc-1: hsl(208, 100%, 45%);
--mx-uc-2: hsl(302, 100%, 30%);
--mx-uc-3: hsl(163, 100%, 30%);
--mx-uc-4: hsl(343, 100%, 45%);
--mx-uc-5: hsl(24, 100%, 45%);
--mx-uc-6: hsl(181, 100%, 30%);
--mx-uc-7: hsl(242, 100%, 45%);
--mx-uc-8: hsl(94, 100%, 35%);
--font-emoji: 'AppleColorEmoji';
--font-secondary: 'Inter Variable', sans-serif;
}
.dark-theme,
.butter-theme,
.discord-theme,
.discord-darker-theme,
.twilight-theme,
.mocha-theme {
--tc-link: hsl(213deg 100% 80%);
--mx-uc-1: hsl(208, 100%, 75%);
--mx-uc-2: hsl(301, 100%, 80%);
--mx-uc-3: hsl(163, 100%, 70%);
--mx-uc-4: hsl(343, 100%, 75%);
--mx-uc-5: hsl(24, 100%, 70%);
--mx-uc-6: hsl(181, 100%, 60%);
--mx-uc-7: hsl(243, 100%, 80%);
--mx-uc-8: hsl(94, 100%, 80%);
--font-secondary: 'Inter Variable', sans-serif;
}
html {
height: 100%;
overflow: hidden;
/* Enable safe area variables */
--safe-area-inset-top: env(safe-area-inset-top, 0px);
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-inset-left: env(safe-area-inset-left, 0px);
--safe-area-inset-right: env(safe-area-inset-right, 0px);
}
body {
margin: 0;
padding: 0;
/* Safe area insets for mobile devices (notch, navigation bar) */
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
height: 100%;
font-family: var(--font-secondary);
font-size: 16px;
font-weight: 400;
/* Default to dark theme background for safe areas */
background-color: #262626;
/*Why font-variant-ligatures => https://github.com/rsms/inter/issues/222 */
font-variant-ligatures: no-contextual;
}
/* Landscape + keyboard: reclaim status-bar inset while chrome is hidden. */
html.immersive-landscape-keyboard {
--safe-area-inset-top: 0px;
}
/* Prevent zalgo text and combining diacritics from overflowing line boundaries */
p, span, div, a, button, li, td, th, h1, h2, h3, h4, h5, h6 {
line-height: 1.5;
}
/* Theme-specific body backgrounds for safe area padding */
body.light-theme {
background-color: #F0F0F0;
}
body.silver-theme {
background-color: #DEDEDE;
}
body.dark-theme {
background-color: #262626;
}
body.butter-theme {
background-color: #1A1916;
}
body.discord-theme {
background-color: #2B2D31;
}
body.discord-darker-theme {
background-color: #1E1F22;
}
body.twilight-theme {
background: linear-gradient(135deg, #14121F 0%, #1C1A2E 50%, #24223D 100%);
}
body.mocha-theme {
background: linear-gradient(135deg, #1A1614 0%, #242019 50%, #2D2721 100%);
}
#root {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: #262626;
}
.twilight-theme #root {
background: linear-gradient(180deg, rgba(28, 26, 46, 0.5) 0%, rgba(36, 34, 61, 0.3) 100%);
}
.mocha-theme #root {
background: linear-gradient(180deg, rgba(36, 32, 25, 0.5) 0%, rgba(45, 39, 33, 0.3) 100%);
}
/* Twilight theme gradient overlays */
.twilight-theme [role="navigation"],
.twilight-theme aside {
background: linear-gradient(180deg, rgba(20, 18, 31, 0.8) 0%, rgba(28, 26, 46, 0.9) 100%);
backdrop-filter: blur(8px);
}
.twilight-theme main {
background: linear-gradient(135deg, rgba(24, 22, 45, 0.4) 0%, rgba(36, 34, 61, 0.3) 100%);
}
.twilight-theme header {
background: linear-gradient(90deg, rgba(20, 18, 31, 0.9) 0%, rgba(28, 26, 46, 0.8) 100%);
}
/* CSS animations for route transitions */
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Apply transition to route changes - works on ALL themes */
[data-route-transition="true"] {
animation: fadeSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Disable route slide animation on compact/mobile — conflicts with swipe gestures */
@media (max-width: 750px) {
[data-route-transition='true'] {
animation: none;
}
}
html.mobile-gesture-lock,
html.mobile-gesture-lock body {
overscroll-behavior: none;
}
html.mobile-gesture-lock [data-timeline-scroll] {
overflow: hidden !important;
touch-action: none;
}
/* Twilight theme enhanced transitions */
.twilight-theme [data-route-transition="true"] {
animation: fadeSlideIn 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Also apply to main content areas for double coverage */
.twilight-theme [data-room-view],
.twilight-theme [data-space-view],
.twilight-theme [data-inbox-view],
.twilight-theme [data-explore-view] {
animation: fadeSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Smooth transitions for interactive elements */
.twilight-theme button,
.twilight-theme [role="button"],
.twilight-theme a,
.twilight-theme input,
.twilight-theme textarea {
transition: background-color 0.2s ease, color 0.2s ease, transform 0.15s ease;
}
.twilight-theme button:hover,
.twilight-theme [role="button"]:hover {
transform: translateY(-1px);
}
.twilight-theme button:active,
.twilight-theme [role="button"]:active {
transform: translateY(0);
}
/* Message transitions */
.twilight-theme [data-message-item] {
transition: background-color 0.2s ease, transform 0.2s ease;
}
.twilight-theme [data-message-item]:hover {
background: linear-gradient(90deg, rgba(62, 58, 106, 0.15) 0%, rgba(86, 80, 150, 0.1) 100%);
transform: translateX(2px);
}
/* Selected item indicator */
.twilight-theme [data-selected="true"],
.twilight-theme [aria-selected="true"],
.twilight-theme [aria-current="true"] {
position: relative;
transition: all 0.2s ease;
}
.twilight-theme [data-selected="true"]::before,
.twilight-theme [aria-selected="true"]::before,
.twilight-theme [aria-current="true"]::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(180deg, #8B7FFF 0%, #6E62E8 100%);
transition: width 0.2s ease;
}
/* Scrollbar styling */
.twilight-theme ::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.twilight-theme ::-webkit-scrollbar-track {
background: rgba(20, 18, 31, 0.5);
}
.twilight-theme ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #3E3A6A 0%, #565096 100%);
border-radius: 5px;
border: 2px solid rgba(20, 18, 31, 0.5);
transition: background 0.2s ease;
}
.twilight-theme ::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #4A4580 0%, #625BAC 100%);
}
/* Mocha theme gradient overlays */
.mocha-theme [role="navigation"],
.mocha-theme aside {
background: linear-gradient(180deg, rgba(26, 22, 20, 0.8) 0%, rgba(36, 32, 25, 0.9) 100%);
backdrop-filter: blur(8px);
}
.mocha-theme main {
background: linear-gradient(135deg, rgba(36, 32, 25, 0.4) 0%, rgba(45, 39, 33, 0.3) 100%);
}
.mocha-theme header {
background: linear-gradient(90deg, rgba(26, 22, 20, 0.9) 0%, rgba(36, 32, 25, 0.8) 100%);
}
/* Mocha theme enhanced transitions */
.mocha-theme [data-route-transition="true"] {
animation: fadeSlideIn 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.mocha-theme [data-room-view],
.mocha-theme [data-space-view],
.mocha-theme [data-inbox-view],
.mocha-theme [data-explore-view] {
animation: fadeSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Smooth transitions for interactive elements */
.mocha-theme button,
.mocha-theme [role="button"],
.mocha-theme a,
.mocha-theme input,
.mocha-theme textarea {
transition: background-color 0.2s ease, color 0.2s ease, transform 0.15s ease;
}
.mocha-theme button:hover,
.mocha-theme [role="button"]:hover {
transform: translateY(-1px);
}
.mocha-theme button:active,
.mocha-theme [role="button"]:active {
transform: translateY(0);
}
/* Message transitions */
.mocha-theme [data-message-item] {
transition: background-color 0.2s ease, transform 0.2s ease;
}
.mocha-theme [data-message-item]:hover {
background: linear-gradient(90deg, rgba(84, 73, 63, 0.15) 0%, rgba(112, 97, 81, 0.1) 100%);
transform: translateX(2px);
}
/* Selected item indicator */
.mocha-theme [data-selected="true"],
.mocha-theme [aria-selected="true"],
.mocha-theme [aria-current="true"] {
position: relative;
transition: all 0.2s ease;
}
.mocha-theme [data-selected="true"]::before,
.mocha-theme [aria-selected="true"]::before,
.mocha-theme [aria-current="true"]::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(180deg, #D4A574 0%, #B48660 100%);
transition: width 0.2s ease;
}
/* Scrollbar styling */
.mocha-theme ::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.mocha-theme ::-webkit-scrollbar-track {
background: rgba(26, 22, 20, 0.5);
}
.mocha-theme ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #54493F 0%, #706151 100%);
border-radius: 5px;
border: 2px solid rgba(26, 22, 20, 0.5);
transition: background 0.2s ease;
}
.mocha-theme ::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #625548 0%, #7E6D5A 100%);
}
/* Subtle dot pattern overlay */
.mocha-theme::after {
content: '';
position: fixed;
inset: 0;
background-image: radial-gradient(rgba(212, 165, 116, 0.03) 1px, transparent 1px);
background-size: 24px 24px;
pointer-events: none;
z-index: 0;
}
.mocha-theme #root {
position: relative;
z-index: 1;
}
/* Focus states */
.mocha-theme input:focus,
.mocha-theme textarea:focus,
.mocha-theme button:focus-visible {
outline: 2px solid rgba(212, 165, 116, 0.5);
outline-offset: 2px;
transition: outline 0.2s ease;
}
/* Subtle dot pattern overlay */
.twilight-theme::after {
content: '';
position: fixed;
inset: 0;
background-image: radial-gradient(rgba(139, 127, 255, 0.03) 1px, transparent 1px);
background-size: 24px 24px;
pointer-events: none;
z-index: 0;
}
.twilight-theme #root {
position: relative;
z-index: 1;
}
/* Focus states */
.twilight-theme input:focus,
.twilight-theme textarea:focus,
.twilight-theme button:focus-visible {
outline: 2px solid rgba(139, 127, 255, 0.5);
outline-offset: 2px;
transition: outline 0.2s ease;
}
*,
*::before,
*::after {
box-sizing: border-box;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
-webkit-tap-highlight-color: transparent;
}
a {
color: var(--tc-link);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
[data-mx-spoiler][aria-pressed='true'] a {
color: transparent;
pointer-events: none;
}
b {
font-weight: 500;
}
label {
margin: 0;
padding: 0;
}
button,
textarea {
margin: 0;
padding: 0;
background-color: transparent;
color: inherit;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
letter-spacing: inherit;
border: none;
}
button {
max-width: 100%;
text-transform: none;
text-align: inherit;
overflow: visible;
-webkit-appearance: button;
}
textarea,
input,
input[type],
input[type='text'],
input[type='username'],
input[type='password'],
input[type='email'],
input[type='checkbox'] {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
textarea {
color: inherit;
word-spacing: inherit;
}
audio:not([controls]) {
display: none !important;
}
/* Android WebView: Inter Variable + emoji fonts in the text stack break space metrics */
html.android-capacitor,
html.android-capacitor.dark-theme,
html.android-capacitor.butter-theme,
html.android-capacitor.discord-theme,
html.android-capacitor.discord-darker-theme,
html.android-capacitor.twilight-theme,
html.android-capacitor.mocha-theme,
html.android-capacitor.light-theme,
html.android-capacitor.silver-theme {
--font-secondary: system-ui, Roboto, 'Noto Sans', sans-serif;
}
html.android-capacitor body {
font-family: system-ui, Roboto, 'Noto Sans', sans-serif;
letter-spacing: normal;
word-spacing: normal;
font-variant-ligatures: normal;
}
/* Mobile: hide all scrollbars (touch scrolling still works) */
@media (max-width: 750px) {
*,
*::before,
*::after {
scrollbar-width: none;
-ms-overflow-style: none;
}
*::-webkit-scrollbar,
*::-webkit-scrollbar-thumb,
*::-webkit-scrollbar-track,
*::-webkit-scrollbar-corner {
display: none;
width: 0;
height: 0;
background: transparent;
}
.twilight-theme ::-webkit-scrollbar,
.twilight-theme ::-webkit-scrollbar-thumb,
.twilight-theme ::-webkit-scrollbar-track,
.mocha-theme ::-webkit-scrollbar,
.mocha-theme ::-webkit-scrollbar-thumb,
.mocha-theme ::-webkit-scrollbar-track {
display: none;
width: 0;
height: 0;
background: transparent;
}
}
html.android-capacitor,
html.android-capacitor *,
html.android-capacitor *::before,
html.android-capacitor *::after {
scrollbar-width: none;
-ms-overflow-style: none;
}
html.android-capacitor *::-webkit-scrollbar,
html.android-capacitor *::-webkit-scrollbar-thumb,
html.android-capacitor *::-webkit-scrollbar-track,
html.android-capacitor *::-webkit-scrollbar-corner {
display: none;
width: 0;
height: 0;
background: transparent;
}

112
overlay/src/index.tsx Normal file
View File

@@ -0,0 +1,112 @@
/* eslint-disable import/first */
import React from 'react';
import { createRoot } from 'react-dom/client';
import { enableMapSet } from 'immer';
await import('./font-setup');
import 'folds/dist/style.css';
import { configClass, varsClass } from 'folds';
enableMapSet();
import './index.css';
import { trimTrailingSlash } from './app/utils/common';
import App from './app/pages/App';
import { applySafeAreaInsets, isTauri } from './app/utils/tauri';
import { enableViewTransitionsForNavigation } from './app/utils/viewTransitions';
// import i18n (needs to be bundled ;))
import './app/i18n';
document.body.classList.add(configClass, varsClass);
// Apply safe area insets for mobile devices
applySafeAreaInsets();
// Enable View Transitions API for smooth navigation
enableViewTransitionsForNavigation();
// Register Service Worker
if ('serviceWorker' in navigator) {
const swUrl =
import.meta.env.MODE === 'production'
? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
: `/dev-sw.js?dev-sw`;
navigator.serviceWorker.register(swUrl);
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data?.type === 'token' && event.data?.responseKey) {
const getCurrentAccessToken = async () => {
const { getCurrentAccessToken: getToken } = await import('./app/utils/auth');
return getToken();
};
getCurrentAccessToken().then((token) => {
event.source!.postMessage({
responseKey: event.data.responseKey,
token,
});
});
}
});
}
const isElectron = (): boolean => 'electron' in window;
async function checkForUpdates() {
if (isElectron()) {
console.log('Update check skipped - Electron handles updates natively');
return;
}
if (!isTauri() || isElectron()) {
console.log('Update check skipped - not running in Tauri');
return;
}
console.log('Checking for updates...');
try {
const { check } = await import('@tauri-apps/plugin-updater');
const { ask } = await import('@tauri-apps/plugin-dialog');
const { relaunch } = await import('@tauri-apps/plugin-process');
const update = await check();
console.log('Update check result:', update);
if (update) {
console.log(`Update available: ${update.version}`);
const shouldUpdate = await ask(
`A new version (${update.version}) is available. Would you like to update now?`,
{ title: 'Update Available', kind: 'info' }
);
if (shouldUpdate) {
console.log('User chose to update, downloading...');
await update.downloadAndInstall();
await relaunch();
}
} else {
console.log('App is up to date');
}
} catch (error) {
console.error('Failed to check for updates:', error);
}
}
const mountApp = () => {
const rootContainer = document.getElementById('root');
if (rootContainer === null) {
console.error('Root container element not found!');
return;
}
const root = createRoot(rootContainer);
root.render(<App />);
};
mountApp();
setTimeout(() => {
checkForUpdates();
}, 3000);

16
package-lock.json generated
View File

@@ -1,18 +1,19 @@
{
"name": "paarrot",
"version": "4.11.77",
"version": "4.11.121",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paarrot",
"version": "4.11.77",
"version": "4.11.121",
"license": "AGPL-3.0-only",
"dependencies": {
"@capacitor/android": "8.3.0",
"@capacitor/cli": "8.3.0",
"@capacitor/core": "8.3.0",
"@capacitor/local-notifications": "8.0.2"
"@capacitor/local-notifications": "8.0.2",
"@capacitor/status-bar": "8.0.1"
},
"devDependencies": {
"node-fetch": "3.3.2",
@@ -82,6 +83,15 @@
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/status-bar": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.1.tgz",
"integrity": "sha512-OR59dlbwvmrV5dKsC9lvwv48QaGbqcbSTBpk+9/WXWxXYSdXXdzJZU9p8oyNPAkuJhCdnSa3XmU43fZRPBJJ5w==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "paarrot",
"version": "4.11.107",
"version": "4.11.128",
"description": "Paarrot - A Matrix client based on Cinny",
"engines": {
"node": ">=18.0.0"
@@ -25,7 +25,8 @@
"@capacitor/android": "8.3.0",
"@capacitor/cli": "8.3.0",
"@capacitor/core": "8.3.0",
"@capacitor/local-notifications": "8.0.2"
"@capacitor/local-notifications": "8.0.2",
"@capacitor/status-bar": "8.0.1"
},
"devDependencies": {
"node-fetch": "3.3.2",