Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cacf555e5 | ||
| 5a1579dd01 | |||
| 792c735ffe | |||
|
|
32af57d9f1 | ||
| 197474a049 | |||
| 19252fdbb0 | |||
|
|
b260f741d5 | ||
| 7655759bbc | |||
| d30b98c987 | |||
|
|
70d6f6ff42 | ||
| 97f0a15ae2 | |||
| 4cf19131bf | |||
|
|
83137c92a9 | ||
| 426ddf5650 | |||
| 7800e7ced7 | |||
|
|
a5afa6b493 | ||
| afeb45f59d | |||
| 6f45f94f19 | |||
|
|
fbcb291b9a | ||
| b7ec975fb0 | |||
|
|
3f5d5dec98 | ||
| c7b6e8f5f0 | |||
|
|
f709998c50 | ||
| ddcda65c1f | |||
| 6e32bf302a | |||
|
|
ca51f93ecd | ||
| 47c525cc5a | |||
| a282fce14a | |||
|
|
f1121839d4 | ||
| 585eeecb1b | |||
| 72dbbe29b9 | |||
|
|
b6000c1db8 | ||
| 83b4cbbd8e | |||
|
|
8753ed2600 | ||
| cced6b8ab9 | |||
| 82effc9680 | |||
|
|
deefb40277 | ||
| b496c70190 | |||
|
|
5a4384ea1b | ||
| cc974414f8 | |||
| 3245f41073 | |||
|
|
8b51d78db6 | ||
| e27ec9d7ab | |||
|
|
5c18e38314 | ||
| 64e1de954a | |||
|
|
0404cdc8ee | ||
| 9dc177f527 | |||
| 655603bb59 | |||
|
|
fb9e8bc277 | ||
| 803a076f98 | |||
| 86d284b353 | |||
| 6207d18b3c | |||
|
|
8d2e35d1bc | ||
| 6845398755 | |||
| be52b0fa08 | |||
|
|
298bcfd1e3 | ||
| fce1736312 | |||
|
|
e11304d093 | ||
| 60d76d5165 | |||
|
|
373e700a07 | ||
| f7b25261cc | |||
|
|
41e1b0db9d | ||
| d1022635d2 | |||
|
|
2e6be4b0b3 | ||
| 0d97676edd | |||
|
|
f8e8058de3 | ||
| a1577a3b97 | |||
|
|
80f1df6588 | ||
| 375fa74309 | |||
|
|
0862094e00 | ||
| 45d66bbe28 | |||
|
|
919bc310a7 | ||
| badcfb160c |
@@ -27,8 +27,8 @@ android {
|
|||||||
applicationId "com.paarrot.app"
|
applicationId "com.paarrot.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1777176952
|
versionCode 1783582912
|
||||||
versionName "2026-04-26.041552.151"
|
versionName "2026-07-09.074152.772"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/*",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.paarrot.app;
|
package com.paarrot.app;
|
||||||
|
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.webkit.WebView;
|
||||||
|
import androidx.core.view.ViewCompat;
|
||||||
import com.getcapacitor.BridgeActivity;
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
public class MainActivity extends BridgeActivity {
|
public class MainActivity extends BridgeActivity {
|
||||||
@@ -8,6 +10,20 @@ public class MainActivity extends BridgeActivity {
|
|||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
registerPlugin(SyncServicePlugin.class);
|
registerPlugin(SyncServicePlugin.class);
|
||||||
registerPlugin(ShareHandlerPlugin.class);
|
registerPlugin(ShareHandlerPlugin.class);
|
||||||
|
registerPlugin(PluginStoragePlugin.class);
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
setupImageKeyboardSupport();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advertise image MIME types and receive Gboard / paste / drag-drop media. */
|
||||||
|
private void setupImageKeyboardSupport() {
|
||||||
|
if (bridge == null) return;
|
||||||
|
WebView webView = bridge.getWebView();
|
||||||
|
if (webView == null) return;
|
||||||
|
ViewCompat.setOnReceiveContentListener(
|
||||||
|
webView,
|
||||||
|
ImageKeyboardWebView.MIME_TYPES,
|
||||||
|
new ImageKeyboardContentReceiver()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
250
android/app/src/main/java/com/paarrot/app/PluginStoragePlugin.kt
Normal file
250
android/app/src/main/java/com/paarrot/app/PluginStoragePlugin.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,7 +62,6 @@ object ShareIntentStore {
|
|||||||
|
|
||||||
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return
|
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return
|
||||||
|
|
||||||
val receivedAt = System.currentTimeMillis()
|
|
||||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||||
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
|
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
|
||||||
|
|
||||||
@@ -86,8 +85,29 @@ object ShareIntentStore {
|
|||||||
uri?.let { uris.add(it) }
|
uri?.let { uris.add(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deliverShare(context, uris, text, subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver content URIs from the IME (Gboard GIF), clipboard paste, or drag-drop
|
||||||
|
* into the same share pipeline used by ACTION_SEND.
|
||||||
|
*/
|
||||||
|
fun handleContentUris(context: Context, uris: List<Uri>) {
|
||||||
|
if (uris.isEmpty()) return
|
||||||
|
deliverShare(context, uris, text = null, subject = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deliverShare(
|
||||||
|
context: Context,
|
||||||
|
uris: List<Uri>,
|
||||||
|
text: String?,
|
||||||
|
subject: String?,
|
||||||
|
) {
|
||||||
|
val receivedAt = System.currentTimeMillis()
|
||||||
val copiedFiles = uris.mapNotNull { copySharedUri(context, it, receivedAt) }
|
val copiedFiles = uris.mapNotNull { copySharedUri(context, it, receivedAt) }
|
||||||
|
|
||||||
|
if (copiedFiles.isEmpty() && text.isNullOrBlank() && subject.isNullOrBlank()) return
|
||||||
|
|
||||||
val payload = SharePayload(
|
val payload = SharePayload(
|
||||||
text = text,
|
text = text,
|
||||||
subject = subject,
|
subject = subject,
|
||||||
|
|||||||
@@ -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
2
cinny
Submodule cinny updated: 7ef9939d8a...9c7f71896c
@@ -1,3 +1,5 @@
|
|||||||
{
|
{
|
||||||
"dependencies": {}
|
"dependencies": {
|
||||||
|
"@capacitor/status-bar": "8.0.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
overlay/src/app/components/AnimatedOutlet.tsx
Normal file
40
overlay/src/app/components/AnimatedOutlet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
overlay/src/app/components/editor/Editor.css.ts
Normal file
79
overlay/src/app/components/editor/Editor.css.ts
Normal 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,
|
||||||
|
});
|
||||||
287
overlay/src/app/components/editor/Editor.tsx
Normal file
287
overlay/src/app/components/editor/Editor.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
/* eslint-disable no-param-reassign */
|
||||||
|
import React, {
|
||||||
|
ClipboardEventHandler,
|
||||||
|
KeyboardEventHandler,
|
||||||
|
ReactNode,
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
import { Box, Scroll, Text } from 'folds';
|
||||||
|
import { Descendant, Editor, createEditor, Transforms, Range, Element as SlateElement, Text as SlateText, Point } from 'slate';
|
||||||
|
import {
|
||||||
|
Slate,
|
||||||
|
Editable,
|
||||||
|
withReact,
|
||||||
|
RenderLeafProps,
|
||||||
|
RenderElementProps,
|
||||||
|
RenderPlaceholderProps,
|
||||||
|
ReactEditor,
|
||||||
|
} from 'slate-react';
|
||||||
|
import { withHistory } from 'slate-history';
|
||||||
|
import { BlockType } from './types';
|
||||||
|
import { RenderElement, RenderLeaf } from './Elements';
|
||||||
|
import { CustomElement } from './slate';
|
||||||
|
import * as css from './Editor.css';
|
||||||
|
import { toggleKeyboardShortcut } from './keyboard';
|
||||||
|
import { createCommandElement } from './utils';
|
||||||
|
|
||||||
|
const initialValue: CustomElement[] = [
|
||||||
|
{
|
||||||
|
type: BlockType.Paragraph,
|
||||||
|
children: [{ text: '' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const withInline = (editor: Editor): Editor => {
|
||||||
|
const { isInline } = editor;
|
||||||
|
|
||||||
|
editor.isInline = (element) =>
|
||||||
|
[BlockType.Mention, BlockType.Emoticon, BlockType.Link, BlockType.Command].includes(
|
||||||
|
element.type
|
||||||
|
) || isInline(element);
|
||||||
|
|
||||||
|
return editor;
|
||||||
|
};
|
||||||
|
|
||||||
|
const withVoid = (editor: Editor): Editor => {
|
||||||
|
const { isVoid } = editor;
|
||||||
|
|
||||||
|
editor.isVoid = (element) =>
|
||||||
|
[BlockType.Mention, BlockType.Emoticon, BlockType.Command].includes(element.type) ||
|
||||||
|
isVoid(element);
|
||||||
|
|
||||||
|
return editor;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CommandValidatorFn = (commandName: string) => boolean;
|
||||||
|
|
||||||
|
let commandValidator: CommandValidatorFn | null = null;
|
||||||
|
|
||||||
|
export const setCommandValidator = (validator: CommandValidatorFn) => {
|
||||||
|
commandValidator = validator;
|
||||||
|
};
|
||||||
|
|
||||||
|
const withCommandAutoConvert = (editor: Editor): Editor => {
|
||||||
|
// Removed auto-conversion on space to prevent focus issues
|
||||||
|
// Commands are now highlighted visually via decorations
|
||||||
|
// and executed when Enter is pressed
|
||||||
|
return editor;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useEditor = (): Editor => {
|
||||||
|
const [editor] = useState(() =>
|
||||||
|
withCommandAutoConvert(withInline(withVoid(withReact(withHistory(createEditor())))))
|
||||||
|
);
|
||||||
|
return editor;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EditorChangeHandler = (value: Descendant[]) => void;
|
||||||
|
type CustomEditorProps = {
|
||||||
|
editableName?: string;
|
||||||
|
top?: ReactNode;
|
||||||
|
bottom?: ReactNode;
|
||||||
|
before?: ReactNode;
|
||||||
|
after?: ReactNode;
|
||||||
|
maxHeight?: string;
|
||||||
|
editor: Editor;
|
||||||
|
placeholder?: string;
|
||||||
|
onKeyDown?: KeyboardEventHandler;
|
||||||
|
onKeyUp?: KeyboardEventHandler;
|
||||||
|
onChange?: EditorChangeHandler;
|
||||||
|
onPaste?: ClipboardEventHandler;
|
||||||
|
};
|
||||||
|
export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
editableName,
|
||||||
|
top,
|
||||||
|
bottom,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
maxHeight = '50vh',
|
||||||
|
editor,
|
||||||
|
placeholder,
|
||||||
|
onKeyDown,
|
||||||
|
onKeyUp,
|
||||||
|
onChange,
|
||||||
|
onPaste,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const renderElement = useCallback(
|
||||||
|
(props: RenderElementProps) => <RenderElement {...props} />,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderLeaf = useCallback((props: RenderLeafProps) => <RenderLeaf {...props} />, []);
|
||||||
|
|
||||||
|
const decorate = useCallback(([node, path]: [any, number[]]) => {
|
||||||
|
const ranges: any[] = [];
|
||||||
|
|
||||||
|
// Only decorate text nodes in the first paragraph
|
||||||
|
if (
|
||||||
|
path.length === 2 &&
|
||||||
|
path[0] === 0 &&
|
||||||
|
path[1] === 0 &&
|
||||||
|
SlateText.isText(node)
|
||||||
|
) {
|
||||||
|
const firstChild = editor.children[0];
|
||||||
|
|
||||||
|
if (SlateElement.isElement(firstChild) && firstChild.type === BlockType.Paragraph) {
|
||||||
|
const [firstInline, secondInline] = firstChild.children;
|
||||||
|
|
||||||
|
// Don't decorate if we already have a CommandElement
|
||||||
|
if (SlateElement.isElement(secondInline) && secondInline.type === BlockType.Command) {
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the text matches /command pattern
|
||||||
|
const text = node.text;
|
||||||
|
const match = text.match(/^(\s*\/\w+)/);
|
||||||
|
|
||||||
|
if (match && commandValidator) {
|
||||||
|
const commandText = match[1].trim();
|
||||||
|
const commandName = commandText.substring(1);
|
||||||
|
|
||||||
|
if (commandValidator(commandName)) {
|
||||||
|
// Create decoration for the command text
|
||||||
|
ranges.push({
|
||||||
|
anchor: { path, offset: 0 },
|
||||||
|
focus: { path, offset: match[1].length },
|
||||||
|
pendingCommand: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const handleKeydown: KeyboardEventHandler = useCallback(
|
||||||
|
(evt) => {
|
||||||
|
onKeyDown?.(evt);
|
||||||
|
const shortcutToggled = toggleKeyboardShortcut(editor, evt);
|
||||||
|
if (shortcutToggled) evt.preventDefault();
|
||||||
|
},
|
||||||
|
[editor, onKeyDown]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBeforeInput = useCallback(
|
||||||
|
(event: Event) => {
|
||||||
|
const inputEvent = event as InputEvent;
|
||||||
|
|
||||||
|
// Handle mobile autocorrect replacement that causes text duplication in Slate
|
||||||
|
if (inputEvent.inputType === 'insertReplacementText') {
|
||||||
|
const data = inputEvent.data || inputEvent.dataTransfer?.getData('text/plain');
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const domRanges = inputEvent.getTargetRanges?.() ?? [];
|
||||||
|
if (domRanges.length > 0) {
|
||||||
|
const slateRange = ReactEditor.toSlateRange(editor, domRanges[0], {
|
||||||
|
exactMatch: false,
|
||||||
|
suppressThrow: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (slateRange) {
|
||||||
|
Transforms.select(editor, slateRange);
|
||||||
|
Transforms.delete(editor);
|
||||||
|
editor.insertText(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { selection } = editor;
|
||||||
|
if (!selection) return;
|
||||||
|
|
||||||
|
if (!Range.isCollapsed(selection)) {
|
||||||
|
Transforms.delete(editor, { at: selection });
|
||||||
|
} else {
|
||||||
|
const wordBefore = Editor.before(editor, selection.anchor, { unit: 'word' });
|
||||||
|
if (wordBefore) {
|
||||||
|
const wordRange = { anchor: wordBefore, focus: selection.anchor };
|
||||||
|
Transforms.select(editor, wordRange);
|
||||||
|
Transforms.delete(editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.insertText(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
);
|
||||||
|
|
||||||
|
const editableRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const editableElement = editableRef.current?.querySelector('[data-slate-editor="true"]');
|
||||||
|
if (!editableElement) return;
|
||||||
|
|
||||||
|
editableElement.addEventListener('beforeinput', handleBeforeInput, { capture: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
editableElement.removeEventListener('beforeinput', handleBeforeInput, { capture: true });
|
||||||
|
};
|
||||||
|
}, [handleBeforeInput]);
|
||||||
|
|
||||||
|
const renderPlaceholder = useCallback(
|
||||||
|
({ attributes, children }: RenderPlaceholderProps) => (
|
||||||
|
<span {...attributes} className={css.EditorPlaceholderContainer}>
|
||||||
|
{/* Inner component to style the actual text position and appearance */}
|
||||||
|
<Text as="span" className={css.EditorPlaceholderTextVisual} truncate>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.Editor} ref={ref}>
|
||||||
|
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
|
||||||
|
{top}
|
||||||
|
<Box alignItems="Start">
|
||||||
|
{before && (
|
||||||
|
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
|
||||||
|
{before}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Scroll
|
||||||
|
className={css.EditorTextareaScroll}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
style={{ maxHeight }}
|
||||||
|
size="300"
|
||||||
|
visibility="Hover"
|
||||||
|
hideTrack
|
||||||
|
ref={editableRef}
|
||||||
|
>
|
||||||
|
<Editable
|
||||||
|
data-editable-name={editableName}
|
||||||
|
className={css.EditorTextarea}
|
||||||
|
placeholder={placeholder}
|
||||||
|
renderPlaceholder={renderPlaceholder}
|
||||||
|
renderElement={renderElement}
|
||||||
|
renderLeaf={renderLeaf}
|
||||||
|
decorate={decorate}
|
||||||
|
onKeyDown={handleKeydown}
|
||||||
|
onKeyUp={onKeyUp}
|
||||||
|
onPaste={onPaste}
|
||||||
|
/>
|
||||||
|
</Scroll>
|
||||||
|
{after && (
|
||||||
|
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
|
||||||
|
{after}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{bottom}
|
||||||
|
</Slate>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
367
overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx
Normal file
367
overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
overlay/src/app/components/mobile/mobile-gestures.css.ts
Normal file
95
overlay/src/app/components/mobile/mobile-gestures.css.ts
Normal 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)',
|
||||||
|
});
|
||||||
15
overlay/src/app/components/mobile/mobileSwipeReplyBridge.ts
Normal file
15
overlay/src/app/components/mobile/mobileSwipeReplyBridge.ts
Normal 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,
|
||||||
|
};
|
||||||
206
overlay/src/app/components/page/Page.tsx
Normal file
206
overlay/src/app/components/page/Page.tsx
Normal 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} />
|
||||||
|
));
|
||||||
@@ -5,6 +5,7 @@ import FocusTrap from 'focus-trap-react';
|
|||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
Header,
|
Header,
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
@@ -62,6 +63,7 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [confirmRoomId, setConfirmRoomId] = useState<string | null>(null);
|
||||||
|
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
const allRoomsSet = useAllJoinedRoomsSet();
|
const allRoomsSet = useAllJoinedRoomsSet();
|
||||||
@@ -107,149 +109,208 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
|
|||||||
const handleRoomClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
|
const handleRoomClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
const roomId = evt.currentTarget.getAttribute('data-room-id');
|
const roomId = evt.currentTarget.getAttribute('data-room-id');
|
||||||
if (roomId) {
|
if (roomId) {
|
||||||
onPick(roomId);
|
setConfirmRoomId(roomId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleConfirm = useCallback(() => {
|
||||||
|
if (confirmRoomId) {
|
||||||
|
onPick(confirmRoomId);
|
||||||
|
}
|
||||||
|
}, [confirmRoomId, onPick]);
|
||||||
|
|
||||||
|
const handleCancelConfirm = useCallback(() => {
|
||||||
|
setConfirmRoomId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const previewText = share.text?.slice(0, 80) ?? share.subject?.slice(0, 80);
|
const previewText = share.text?.slice(0, 80) ?? share.subject?.slice(0, 80);
|
||||||
const fileCount = share.files.length;
|
const fileCount = share.files.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<>
|
||||||
<OverlayCenter>
|
<Overlay open>
|
||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
onDeactivate: onDismiss,
|
onDeactivate: onDismiss,
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
grow="Yes"
|
||||||
|
direction="Column"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 999,
|
||||||
|
backgroundColor: '#1a1d23',
|
||||||
|
paddingTop: '60px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Modal size="300">
|
<Header
|
||||||
<Box grow="Yes" direction="Column" style={{ maxHeight: '85vh' }}>
|
size="500"
|
||||||
<Header
|
style={{ padding: config.space.S400 }}
|
||||||
size="500"
|
>
|
||||||
style={{ padding: config.space.S200, paddingLeft: config.space.S400 }}
|
<Box grow="Yes" direction="Column" gap="100" style={{ minWidth: 0, maxWidth: '80%' }}>
|
||||||
>
|
<Text size="H4">Share to Room</Text>
|
||||||
<Box grow="Yes" direction="Column" gap="100">
|
{(previewText || fileCount > 0) && (
|
||||||
<Text size="H4">Share to Room</Text>
|
<Text size="T200" priority="300" style={{ opacity: 0.7, wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
||||||
{(previewText || fileCount > 0) && (
|
{previewText
|
||||||
<Text size="T200" priority="300" style={{ opacity: 0.7 }}>
|
? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"`
|
||||||
{previewText
|
: `${fileCount} file${fileCount !== 1 ? 's' : ''}`}
|
||||||
? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"`
|
</Text>
|
||||||
: `${fileCount} file${fileCount !== 1 ? 's' : ''}`}
|
)}
|
||||||
</Text>
|
</Box>
|
||||||
)}
|
<Box shrink="No">
|
||||||
</Box>
|
<IconButton size="300" radii="300" onClick={onDismiss}>
|
||||||
<Box shrink="No">
|
<Icon src={Icons.Cross} />
|
||||||
<IconButton size="300" radii="300" onClick={onDismiss}>
|
</IconButton>
|
||||||
<Icon src={Icons.Cross} />
|
</Box>
|
||||||
</IconButton>
|
</Header>
|
||||||
</Box>
|
|
||||||
</Header>
|
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
padding: `0 ${config.space.S300}`,
|
padding: config.space.S200,
|
||||||
paddingTop: config.space.S200,
|
paddingTop: config.space.S300,
|
||||||
paddingBottom: config.space.S200,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Input
|
||||||
<Input
|
onChange={handleSearchChange}
|
||||||
onChange={handleSearchChange}
|
before={<Icon size="200" src={Icons.Search} />}
|
||||||
before={<Icon size="200" src={Icons.Search} />}
|
placeholder="Search rooms…"
|
||||||
placeholder="Search rooms…"
|
size="400"
|
||||||
size="400"
|
variant="Background"
|
||||||
variant="Background"
|
outlined
|
||||||
outlined
|
autoFocus
|
||||||
autoFocus
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Scroll ref={scrollRef} size="300" hideTrack style={{ flex: 1, minHeight: 0 }}>
|
<Scroll ref={scrollRef} size="300" hideTrack style={{ flex: 1, minHeight: 0 }}>
|
||||||
|
<Box style={{ padding: config.space.S300 }} direction="Column">
|
||||||
|
{vItems.length === 0 && (
|
||||||
<Box
|
<Box
|
||||||
style={{ padding: config.space.S300, paddingTop: 0 }}
|
style={{ padding: `${config.space.S700} 0` }}
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
>
|
>
|
||||||
{vItems.length === 0 && (
|
<Text size="H6" align="Center">
|
||||||
<Box
|
{searchResult ? 'No Match Found' : 'No Rooms'}
|
||||||
style={{ padding: `${config.space.S700} 0` }}
|
</Text>
|
||||||
alignItems="Center"
|
<Text size="T200" align="Center" priority="300">
|
||||||
justifyContent="Center"
|
{searchResult
|
||||||
direction="Column"
|
? `No rooms found for "${searchResult.query}".`
|
||||||
gap="100"
|
: 'You have no rooms to share into.'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box style={{ position: 'relative', height: virtualizer.getTotalSize() }}>
|
||||||
|
{vItems.map((vItem) => {
|
||||||
|
const roomId = items[vItem.index];
|
||||||
|
const room = getRoom(roomId);
|
||||||
|
if (!room) return null;
|
||||||
|
const isDm = mDirects.has(roomId);
|
||||||
|
|
||||||
|
const avatarSrc = isDm
|
||||||
|
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||||
|
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VirtualTile
|
||||||
|
virtualItem={vItem}
|
||||||
|
style={{ paddingBottom: config.space.S100 }}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
key={roomId}
|
||||||
>
|
>
|
||||||
<Text size="H6" align="Center">
|
<MenuItem
|
||||||
{searchResult ? 'No Match Found' : 'No Rooms'}
|
data-room-id={roomId}
|
||||||
</Text>
|
onClick={handleRoomClick}
|
||||||
<Text size="T200" align="Center" priority="300">
|
variant="Surface"
|
||||||
{searchResult
|
size="400"
|
||||||
? `No rooms found for "${searchResult.query}".`
|
radii="400"
|
||||||
: 'You have no rooms to share into.'}
|
before={
|
||||||
</Text>
|
<Avatar size="300" radii={isDm ? '400' : '300'}>
|
||||||
</Box>
|
{avatarSrc ? (
|
||||||
)}
|
<RoomAvatar
|
||||||
<Box
|
roomId={roomId}
|
||||||
style={{ position: 'relative', height: virtualizer.getTotalSize() }}
|
src={avatarSrc}
|
||||||
>
|
alt={room.name}
|
||||||
{vItems.map((vItem) => {
|
renderFallback={() => (
|
||||||
const roomId = items[vItem.index];
|
<Text as="span" size="H6">
|
||||||
const room = getRoom(roomId);
|
{nameInitials(room.name)}
|
||||||
if (!room) return null;
|
</Text>
|
||||||
const isDm = mDirects.has(roomId);
|
|
||||||
|
|
||||||
const avatarSrc = isDm
|
|
||||||
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
|
||||||
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VirtualTile
|
|
||||||
virtualItem={vItem}
|
|
||||||
style={{ paddingBottom: config.space.S100 }}
|
|
||||||
ref={virtualizer.measureElement}
|
|
||||||
key={roomId}
|
|
||||||
>
|
|
||||||
<MenuItem
|
|
||||||
data-room-id={roomId}
|
|
||||||
onClick={handleRoomClick}
|
|
||||||
variant="Surface"
|
|
||||||
size="400"
|
|
||||||
radii="400"
|
|
||||||
before={
|
|
||||||
<Avatar size="300" radii={isDm ? '400' : '300'}>
|
|
||||||
{avatarSrc ? (
|
|
||||||
<RoomAvatar
|
|
||||||
roomId={roomId}
|
|
||||||
src={avatarSrc}
|
|
||||||
alt={room.name}
|
|
||||||
renderFallback={() => (
|
|
||||||
<Text as="span" size="H6">
|
|
||||||
{nameInitials(room.name)}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<RoomIcon room={room} size="200" filled />
|
|
||||||
)}
|
)}
|
||||||
</Avatar>
|
/>
|
||||||
}
|
) : (
|
||||||
>
|
<RoomIcon room={room} size="200" filled />
|
||||||
<Box grow="Yes" direction="Column">
|
)}
|
||||||
<Text size="B400" truncate>
|
</Avatar>
|
||||||
{room.name}
|
}
|
||||||
</Text>
|
>
|
||||||
</Box>
|
<Box grow="Yes" direction="Column">
|
||||||
</MenuItem>
|
<Text size="B400" truncate>
|
||||||
</VirtualTile>
|
{room.name}
|
||||||
);
|
</Text>
|
||||||
})}
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
</VirtualTile>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</FocusTrap>
|
||||||
|
</Overlay>
|
||||||
|
|
||||||
|
{confirmRoomId && (() => {
|
||||||
|
const room = getRoom(confirmRoomId);
|
||||||
|
const roomName = room?.name ?? confirmRoomId;
|
||||||
|
const isDm = mDirects.has(confirmRoomId);
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: handleCancelConfirm,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Modal style={{ maxWidth: '280px' }}>
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S300 }}>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="H4">Share to {isDm ? 'Person' : 'Room'}?</Text>
|
||||||
|
<Text size="T300" priority="300">
|
||||||
|
Send to <strong>{roomName}</strong>?
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box gap="200" justifyContent="End">
|
||||||
|
<Button
|
||||||
|
onClick={handleCancelConfirm}
|
||||||
|
variant="Secondary"
|
||||||
|
size="400"
|
||||||
|
>
|
||||||
|
<Text size="B400">Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
variant="Primary"
|
||||||
|
size="400"
|
||||||
|
>
|
||||||
|
<Text size="B400">Share</Text>
|
||||||
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Scroll>
|
</Modal>
|
||||||
</Box>
|
</FocusTrap>
|
||||||
</Modal>
|
</OverlayCenter>
|
||||||
</FocusTrap>
|
</Overlay>
|
||||||
</OverlayCenter>
|
);
|
||||||
</Overlay>
|
})()}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
185
overlay/src/app/features/room/RoomView.tsx
Normal file
185
overlay/src/app/features/room/RoomView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
overlay/src/app/features/room/RoomViewFollowing.css.ts
Normal file
49
overlay/src/app/features/room/RoomViewFollowing.css.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
1405
overlay/src/app/features/room/message/Message.tsx
Normal file
1405
overlay/src/app/features/room/message/Message.tsx
Normal file
File diff suppressed because it is too large
Load Diff
41
overlay/src/app/features/room/replyToMessage.ts
Normal file
41
overlay/src/app/features/room/replyToMessage.ts
Normal 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;
|
||||||
|
};
|
||||||
63
overlay/src/app/features/room/room-composer-mobile.css.ts
Normal file
63
overlay/src/app/features/room/room-composer-mobile.css.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
149
overlay/src/app/features/settings/plugins/PluginAPI.ts
Normal file
149
overlay/src/app/features/settings/plugins/PluginAPI.ts
Normal 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');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
190
overlay/src/app/features/settings/plugins/PluginLoader.tsx
Normal file
190
overlay/src/app/features/settings/plugins/PluginLoader.tsx
Normal 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;
|
||||||
|
}
|
||||||
30
overlay/src/app/hooks/useCompactNav.ts
Normal file
30
overlay/src/app/hooks/useCompactNav.ts
Normal 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;
|
||||||
|
};
|
||||||
111
overlay/src/app/hooks/useFilePasteHandler.ts
Normal file
111
overlay/src/app/hooks/useFilePasteHandler.ts
Normal 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]
|
||||||
|
);
|
||||||
97
overlay/src/app/hooks/useMobileKeyboardLayout.ts
Normal file
97
overlay/src/app/hooks/useMobileKeyboardLayout.ts
Normal 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 };
|
||||||
|
};
|
||||||
57
overlay/src/app/hooks/useViewTransitions.ts
Normal file
57
overlay/src/app/hooks/useViewTransitions.ts
Normal 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]
|
||||||
|
);
|
||||||
|
};
|
||||||
20
overlay/src/app/pages/MobileFriendly.tsx
Normal file
20
overlay/src/app/pages/MobileFriendly.tsx
Normal 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;
|
||||||
|
}
|
||||||
159
overlay/src/app/pages/auth/ServerPicker.tsx
Normal file
159
overlay/src/app/pages/auth/ServerPicker.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import React, {
|
||||||
|
ChangeEventHandler,
|
||||||
|
FocusEventHandler,
|
||||||
|
KeyboardEventHandler,
|
||||||
|
MouseEventHandler,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Input,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
PopOut,
|
||||||
|
RectCords,
|
||||||
|
Text,
|
||||||
|
config,
|
||||||
|
} from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
|
||||||
|
import { useDebounce } from '../../hooks/useDebounce';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
export function ServerPicker({
|
||||||
|
server,
|
||||||
|
serverList,
|
||||||
|
allowCustomServer,
|
||||||
|
onServerChange,
|
||||||
|
}: {
|
||||||
|
server: string;
|
||||||
|
serverList: string[];
|
||||||
|
allowCustomServer?: boolean;
|
||||||
|
onServerChange: (server: string) => void;
|
||||||
|
}) {
|
||||||
|
const [serverMenuAnchor, setServerMenuAnchor] = useState<RectCords>();
|
||||||
|
const serverInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only sync input when server changes externally (e.g., from menu selection)
|
||||||
|
// and input is not focused to avoid cursor jumping during typing
|
||||||
|
if (
|
||||||
|
serverInputRef.current &&
|
||||||
|
serverInputRef.current.value !== server &&
|
||||||
|
document.activeElement !== serverInputRef.current
|
||||||
|
) {
|
||||||
|
serverInputRef.current.value = server;
|
||||||
|
}
|
||||||
|
}, [server]);
|
||||||
|
|
||||||
|
const debounceServerSelect = useDebounce(onServerChange, { wait: 700 });
|
||||||
|
|
||||||
|
const handleServerChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
const inputServer = evt.target.value.trim();
|
||||||
|
if (inputServer) debounceServerSelect(inputServer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur: FocusEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
const inputServer = evt.target.value.trim();
|
||||||
|
if (inputServer && inputServer !== server) {
|
||||||
|
onServerChange(inputServer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
if (evt.key === 'ArrowDown') {
|
||||||
|
evt.preventDefault();
|
||||||
|
setServerMenuAnchor(undefined);
|
||||||
|
}
|
||||||
|
if (evt.key === 'Enter') {
|
||||||
|
evt.preventDefault();
|
||||||
|
const inputServer = evt.currentTarget.value.trim();
|
||||||
|
if (inputServer) onServerChange(inputServer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServerSelect: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
const selectedServer = evt.currentTarget.getAttribute('data-server');
|
||||||
|
if (selectedServer) {
|
||||||
|
onServerChange(selectedServer);
|
||||||
|
}
|
||||||
|
setServerMenuAnchor(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenServerMenu: MouseEventHandler<HTMLElement> = (evt) => {
|
||||||
|
const target = evt.currentTarget.parentElement ?? evt.currentTarget;
|
||||||
|
setServerMenuAnchor(target.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={serverInputRef}
|
||||||
|
style={{ paddingRight: config.space.S200 }}
|
||||||
|
variant={allowCustomServer ? 'Background' : 'Surface'}
|
||||||
|
outlined
|
||||||
|
defaultValue={server}
|
||||||
|
onChange={handleServerChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
size="500"
|
||||||
|
readOnly={!allowCustomServer}
|
||||||
|
onClick={allowCustomServer ? undefined : handleOpenServerMenu}
|
||||||
|
after={
|
||||||
|
serverList.length === 0 || (serverList.length === 1 && !allowCustomServer) ? undefined : (
|
||||||
|
<PopOut
|
||||||
|
anchor={serverMenuAnchor}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
offset={4}
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setServerMenuAnchor(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
|
||||||
|
<Text size="L400">Homeserver List</Text>
|
||||||
|
</Header>
|
||||||
|
<div style={{ padding: config.space.S100, paddingTop: 0 }}>
|
||||||
|
{serverList?.map((serverName) => (
|
||||||
|
<MenuItem
|
||||||
|
key={serverName}
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={serverName === server}
|
||||||
|
data-server={serverName}
|
||||||
|
onClick={handleServerSelect}
|
||||||
|
>
|
||||||
|
<Text>{serverName}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={handleOpenServerMenu}
|
||||||
|
variant={allowCustomServer ? 'Background' : 'Surface'}
|
||||||
|
size="300"
|
||||||
|
aria-pressed={!!serverMenuAnchor}
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.ChevronBottom} />
|
||||||
|
</IconButton>
|
||||||
|
</PopOut>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
overlay/src/app/pages/client/ClientLayout.tsx
Normal file
21
overlay/src/app/pages/client/ClientLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue, useStore } from 'jotai';
|
||||||
import { useAtom, useSetAtom } from 'jotai';
|
|
||||||
import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
|
import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||||
@@ -23,13 +22,12 @@ import {
|
|||||||
guessPerfectParent,
|
guessPerfectParent,
|
||||||
} from '../../utils/room';
|
} from '../../utils/room';
|
||||||
import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
|
import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
|
||||||
import { getMxIdLocalPart, mxcUrlToHttp, getCanonicalAliasOrRoomId } from '../../utils/matrix';
|
import { getMxIdLocalPart, mxcUrlToHttp, getCanonicalAliasOrRoomId, encryptFile } from '../../utils/matrix';
|
||||||
import { mDirectAtom } from '../../state/mDirectList';
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||||
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { plainToEditorInput, toPlainText } from '../../components/editor';
|
|
||||||
import { ShareRoomPicker } from '../../features/ShareRoomPicker';
|
import { ShareRoomPicker } from '../../features/ShareRoomPicker';
|
||||||
import {
|
import {
|
||||||
isTauri,
|
isTauri,
|
||||||
@@ -46,10 +44,10 @@ import {
|
|||||||
} from '../../utils/backgroundSync';
|
} from '../../utils/backgroundSync';
|
||||||
import {
|
import {
|
||||||
TUploadItem,
|
TUploadItem,
|
||||||
roomIdToMsgDraftAtomFamily,
|
|
||||||
roomIdToUploadItemsAtomFamily,
|
roomIdToUploadItemsAtomFamily,
|
||||||
} from '../../state/room/roomInputDrafts';
|
} from '../../state/room/roomInputDrafts';
|
||||||
import { encryptFile } from '../../utils/matrix';
|
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
||||||
|
import { safeFile } from '../../utils/mimeTypes';
|
||||||
import {
|
import {
|
||||||
AndroidSharePayload,
|
AndroidSharePayload,
|
||||||
clearPendingAndroidShare,
|
clearPendingAndroidShare,
|
||||||
@@ -517,86 +515,195 @@ function TaskbarFlashStopper() {
|
|||||||
|
|
||||||
function AndroidShareIntentHandler() {
|
function AndroidShareIntentHandler() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
const store = useStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null);
|
const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null);
|
||||||
const [pickedRoomId, setPickedRoomId] = useState<string | null>(null);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(pickedRoomId ?? '__android_share__'));
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
const setUploadItems = useSetAtom(roomIdToUploadItemsAtomFamily(pickedRoomId ?? '__android_share__'));
|
const selectedRoomId = useSelectedRoom();
|
||||||
|
const selectedRoomIdRef = useRef(selectedRoomId);
|
||||||
|
selectedRoomIdRef.current = selectedRoomId;
|
||||||
|
|
||||||
const applyPendingShare = useCallback(
|
/** Queue shared files into the open room's composer upload board (Gboard / paste / share). */
|
||||||
|
const queueShareAsDrafts = useCallback(
|
||||||
async (share: AndroidSharePayload, roomId: string) => {
|
async (share: AndroidSharePayload, roomId: string) => {
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
if (!room) return false;
|
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(
|
const nextParts = [share.subject?.trim(), share.text?.trim()].filter(
|
||||||
(value): value is string => !!value
|
(value): value is string => !!value
|
||||||
);
|
);
|
||||||
if (nextParts.length > 0) {
|
if (nextParts.length > 0) {
|
||||||
const existingText = toPlainText(msgDraft, isMarkdown).trim();
|
await mx.sendMessage(roomId, {
|
||||||
const nextText = existingText ? `${existingText}\n${nextParts.join('\n')}` : nextParts.join('\n');
|
msgtype: 'm.text' as const,
|
||||||
setMsgDraft(plainToEditorInput(nextText, isMarkdown));
|
body: nextParts.join('\n'),
|
||||||
}
|
|
||||||
|
|
||||||
if (share.files.length > 0) {
|
|
||||||
const uploadItems: TUploadItem[] = [];
|
|
||||||
for (const sharedFile of share.files) {
|
|
||||||
const originalFile = await materializeSharedFile(sharedFile, share.receivedAt);
|
|
||||||
if (room.hasEncryptionStateEvent()) {
|
|
||||||
const encrypted = await encryptFile(originalFile);
|
|
||||||
uploadItems.push({
|
|
||||||
file: encrypted.file,
|
|
||||||
originalFile,
|
|
||||||
metadata: { markedAsSpoiler: false },
|
|
||||||
encInfo: encrypted.encInfo,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
uploadItems.push({
|
|
||||||
file: originalFile,
|
|
||||||
originalFile,
|
|
||||||
metadata: { markedAsSpoiler: false },
|
|
||||||
encInfo: undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploadItems({
|
|
||||||
type: 'PUT',
|
|
||||||
item: uploadItems,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await clearPendingAndroidShare();
|
await clearPendingAndroidShare();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[isMarkdown, msgDraft, mx, setMsgDraft, setUploadItems]
|
[mx, store]
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyPendingShare = useCallback(
|
||||||
|
async (share: AndroidSharePayload, roomId: string) => {
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room) return false;
|
||||||
|
|
||||||
|
// Send text message if present
|
||||||
|
const nextParts = [share.subject?.trim(), share.text?.trim()].filter(
|
||||||
|
(value): value is string => !!value
|
||||||
|
);
|
||||||
|
if (nextParts.length > 0) {
|
||||||
|
const textContent = {
|
||||||
|
msgtype: 'm.text' as const,
|
||||||
|
body: nextParts.join('\n'),
|
||||||
|
};
|
||||||
|
await mx.sendMessage(roomId, textContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload and send files if present
|
||||||
|
if (share.files.length > 0) {
|
||||||
|
for (const sharedFile of share.files) {
|
||||||
|
const originalFile = await materializeSharedFile(sharedFile, share.receivedAt);
|
||||||
|
|
||||||
|
let fileToUpload = originalFile;
|
||||||
|
let encInfo: any = undefined;
|
||||||
|
|
||||||
|
if (room.hasEncryptionStateEvent()) {
|
||||||
|
const encrypted = await encryptFile(originalFile);
|
||||||
|
fileToUpload = encrypted.file;
|
||||||
|
encInfo = encrypted.encInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload file
|
||||||
|
const uploadResult = await mx.uploadContent(fileToUpload);
|
||||||
|
const mxc = uploadResult?.content_uri;
|
||||||
|
if (!mxc) continue;
|
||||||
|
|
||||||
|
// Determine message type and send
|
||||||
|
const fileType = originalFile.type;
|
||||||
|
let msgtype = 'm.file' as const;
|
||||||
|
if (fileType.startsWith('image/')) msgtype = 'm.image' as const;
|
||||||
|
else if (fileType.startsWith('video/')) msgtype = 'm.video' as const;
|
||||||
|
else if (fileType.startsWith('audio/')) msgtype = 'm.audio' as const;
|
||||||
|
|
||||||
|
const fileContent: any = {
|
||||||
|
msgtype,
|
||||||
|
body: originalFile.name,
|
||||||
|
filename: originalFile.name,
|
||||||
|
info: {
|
||||||
|
mimetype: originalFile.type,
|
||||||
|
size: originalFile.size,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (encInfo) {
|
||||||
|
fileContent.file = {
|
||||||
|
...encInfo,
|
||||||
|
url: mxc,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
fileContent.url = mxc;
|
||||||
|
}
|
||||||
|
|
||||||
|
await mx.sendMessage(roomId, fileContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await clearPendingAndroidShare();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
|
||||||
|
const ingestShare = useCallback(
|
||||||
|
(share: AndroidSharePayload) => {
|
||||||
|
const roomId = selectedRoomIdRef.current;
|
||||||
|
// Already in a room: attach to composer (Gboard GIF / paste / in-app share).
|
||||||
|
if (roomId && share.files.length > 0) {
|
||||||
|
queueShareAsDrafts(share, roomId).catch((err) => {
|
||||||
|
console.error('[AndroidShare] Failed to queue share into room:', err);
|
||||||
|
setPendingShare(share);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingShare(share);
|
||||||
|
},
|
||||||
|
[queueShareAsDrafts]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePickRoom = useCallback(
|
const handlePickRoom = useCallback(
|
||||||
(roomId: string) => {
|
(roomId: string) => {
|
||||||
setPickedRoomId(roomId);
|
|
||||||
if (!pendingShare) return;
|
if (!pendingShare) return;
|
||||||
|
|
||||||
applyPendingShare(pendingShare, roomId)
|
applyPendingShare(pendingShare, roomId)
|
||||||
.then((applied) => {
|
.then((applied) => {
|
||||||
if (applied) {
|
if (applied) {
|
||||||
setPendingShare(null);
|
setPendingShare(null);
|
||||||
setPickedRoomId(null);
|
|
||||||
|
// Navigate to the selected room
|
||||||
|
const isDirect = mDirects.has(roomId);
|
||||||
|
if (isDirect) {
|
||||||
|
navigate(getDirectRoomPath(roomId));
|
||||||
|
} else {
|
||||||
|
const parents = roomToParents.get(roomId);
|
||||||
|
const parent = parents && parents.length > 0 ? parents[0] : undefined;
|
||||||
|
if (parent) {
|
||||||
|
navigate(getSpaceRoomPath(parent, roomId));
|
||||||
|
} else {
|
||||||
|
navigate(getHomeRoomPath(roomId));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('[AndroidShare] Failed to apply share after pick:', err);
|
console.error('[AndroidShare] Failed to apply share after pick:', err);
|
||||||
setPendingShare(null);
|
setPendingShare(null);
|
||||||
setPickedRoomId(null);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[applyPendingShare, pendingShare]
|
[applyPendingShare, pendingShare, navigate, mDirects, roomToParents]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDismiss = useCallback(() => {
|
const handleDismiss = useCallback(() => {
|
||||||
clearPendingAndroidShare().catch(() => {});
|
clearPendingAndroidShare().catch(() => {});
|
||||||
setPendingShare(null);
|
setPendingShare(null);
|
||||||
setPickedRoomId(null);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -608,7 +715,7 @@ function AndroidShareIntentHandler() {
|
|||||||
getPendingAndroidShare()
|
getPendingAndroidShare()
|
||||||
.then((share) => {
|
.then((share) => {
|
||||||
if (mounted && share) {
|
if (mounted && share) {
|
||||||
setPendingShare(share);
|
ingestShare(share);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -617,7 +724,7 @@ function AndroidShareIntentHandler() {
|
|||||||
|
|
||||||
listenForAndroidShares((share) => {
|
listenForAndroidShares((share) => {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setPendingShare(share);
|
ingestShare(share);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then((handle) => {
|
.then((handle) => {
|
||||||
@@ -631,7 +738,7 @@ function AndroidShareIntentHandler() {
|
|||||||
mounted = false;
|
mounted = false;
|
||||||
void listenerHandle?.remove();
|
void listenerHandle?.remove();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [ingestShare]);
|
||||||
|
|
||||||
if (!pendingShare) return null;
|
if (!pendingShare) return null;
|
||||||
|
|
||||||
|
|||||||
72
overlay/src/app/utils/pluginHost.ts
Normal file
72
overlay/src/app/utils/pluginHost.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -248,6 +248,24 @@ export const isAndroid = (): boolean => {
|
|||||||
return ua.includes('android');
|
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
|
* Apply safe area insets for mobile devices
|
||||||
* On Android, CSS env() may not work, so we apply fallback padding
|
* On Android, CSS env() may not work, so we apply fallback padding
|
||||||
|
|||||||
81
overlay/src/app/utils/viewTransitions.ts
Normal file
81
overlay/src/app/utils/viewTransitions.ts
Normal 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
18
overlay/src/font-setup.ts
Normal 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
578
overlay/src/index.css
Normal 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: 'InterVariable', 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: 'InterVariable', 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
112
overlay/src/index.tsx
Normal 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
16
package-lock.json
generated
@@ -1,18 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.77",
|
"version": "4.11.121",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.77",
|
"version": "4.11.121",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.3.0",
|
"@capacitor/android": "8.3.0",
|
||||||
"@capacitor/cli": "8.3.0",
|
"@capacitor/cli": "8.3.0",
|
||||||
"@capacitor/core": "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": {
|
"devDependencies": {
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
@@ -82,6 +83,15 @@
|
|||||||
"@capacitor/core": ">=8.0.0"
|
"@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": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.97",
|
"version": "4.11.126",
|
||||||
"description": "Paarrot - A Matrix client based on Cinny",
|
"description": "Paarrot - A Matrix client based on Cinny",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
@@ -25,7 +25,8 @@
|
|||||||
"@capacitor/android": "8.3.0",
|
"@capacitor/android": "8.3.0",
|
||||||
"@capacitor/cli": "8.3.0",
|
"@capacitor/cli": "8.3.0",
|
||||||
"@capacitor/core": "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": {
|
"devDependencies": {
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user