Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60edb063dd | ||
| 0c85c36838 | |||
|
|
d59646c630 | ||
| c1985eda15 | |||
|
|
2e018b0367 | ||
| 8046474b8a | |||
|
|
9821b16968 | ||
| a887cd39f1 | |||
|
|
ac863b3c1a | ||
| a889b67c2e | |||
|
|
4618d59605 | ||
| 697f03bc51 | |||
|
|
670aaf6a20 | ||
| c4a6b4b175 | |||
| a5efb1f337 | |||
|
|
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 |
@@ -103,28 +103,6 @@ jobs:
|
||||
- name: Build Android APK
|
||||
run: npm run android:apk:linux
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: Paarrot-Android.apk
|
||||
path: android/app/build/outputs/apk/debug/Paarrot-*.apk
|
||||
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [increment-version, build-android]
|
||||
if: always() && needs.build-android.result == 'success'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.ref_name }}
|
||||
|
||||
- name: Pull latest (after version bump)
|
||||
if: github.ref == 'refs/heads/master'
|
||||
run: git pull origin master
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
@@ -136,6 +114,13 @@ jobs:
|
||||
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Version: $VERSION"
|
||||
|
||||
- name: Prepare release files
|
||||
run: |
|
||||
mkdir -p release-files
|
||||
find android/app/build/outputs/apk/debug -type f -name "Paarrot-*.apk" -exec cp {} release-files/ \;
|
||||
ls -la release-files/
|
||||
test -n "$(ls -A release-files/*.apk 2>/dev/null)"
|
||||
|
||||
- name: Check if tag exists
|
||||
id: tag_check
|
||||
run: |
|
||||
@@ -156,17 +141,8 @@ jobs:
|
||||
git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}"
|
||||
git push origin "v${{ steps.version.outputs.VERSION }}"
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Prepare release files
|
||||
run: |
|
||||
mkdir -p release-files
|
||||
find artifacts -type f -name "*.apk" -exec cp {} release-files/ \;
|
||||
ls -la release-files/
|
||||
|
||||
# Upload via the internal Gitea API. actions/upload-artifact times out against
|
||||
# git.ruv.wtf:443 from this runner; synbox:8418 is reachable on the LAN.
|
||||
- name: Create or Update Gitea Release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.gitmodules
vendored
2
.gitmodules
vendored
@@ -1,3 +1,3 @@
|
||||
[submodule "cinny"]
|
||||
path = cinny
|
||||
url = http://synbox.ruv.wtf:8418/litruv/cinny.git
|
||||
url = ../cinny.git
|
||||
|
||||
@@ -27,8 +27,8 @@ android {
|
||||
applicationId "com.paarrot.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1783526741
|
||||
versionName "2026-07-08.160541.766"
|
||||
versionCode 1783582912
|
||||
versionName "2026-07-09.074152.772"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -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;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.webkit.WebView;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
@@ -10,5 +12,18 @@ public class MainActivity extends BridgeActivity {
|
||||
registerPlugin(ShareHandlerPlugin.class);
|
||||
registerPlugin(PluginStoragePlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
setupImageKeyboardSupport();
|
||||
}
|
||||
|
||||
/** Advertise image MIME types and receive Gboard / paste / drag-drop media. */
|
||||
private void setupImageKeyboardSupport() {
|
||||
if (bridge == null) return;
|
||||
WebView webView = bridge.getWebView();
|
||||
if (webView == null) return;
|
||||
ViewCompat.setOnReceiveContentListener(
|
||||
webView,
|
||||
ImageKeyboardWebView.MIME_TYPES,
|
||||
new ImageKeyboardContentReceiver()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,21 @@ class MatrixSyncService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private enum class RoomNotifyMode {
|
||||
MUTE,
|
||||
ALL_MESSAGES,
|
||||
MENTIONS_AND_KEYWORDS,
|
||||
}
|
||||
|
||||
private data class NotifyContext(
|
||||
val myUserId: String,
|
||||
val myDisplayName: String?,
|
||||
val myLocalpart: String?,
|
||||
val keywords: List<String>,
|
||||
val directRoomIds: Set<String>,
|
||||
val pushRules: JSONObject?,
|
||||
)
|
||||
|
||||
private fun processRoomEvents(sync: JSONObject, myUserId: String) {
|
||||
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
|
||||
val homeserver = prefs.getString(EXTRA_HOMESERVER, null)?.trimEnd('/') ?: return
|
||||
@@ -159,10 +174,20 @@ class MatrixSyncService : Service() {
|
||||
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
ensureMessageChannel(nm)
|
||||
|
||||
val notifyCtx = loadNotifyContext(homeserver, token, myUserId)
|
||||
|
||||
val roomIds = joinedRooms.keys().asSequence().toList()
|
||||
for (roomId in roomIds) {
|
||||
val timeline = joinedRooms.optJSONObject(roomId)
|
||||
?.optJSONObject("timeline") ?: continue
|
||||
val roomData = joinedRooms.optJSONObject(roomId) ?: continue
|
||||
val mode = resolveRoomNotifyMode(roomId, notifyCtx)
|
||||
|
||||
// Homeserver unread counts already apply push rules — skip rooms with nothing to notify.
|
||||
val unread = roomData.optJSONObject("unread_notifications")
|
||||
val notificationCount = unread?.optInt("notification_count", 0) ?: 0
|
||||
if (mode == RoomNotifyMode.MUTE || notificationCount <= 0) continue
|
||||
|
||||
val mentionsOnly = mode == RoomNotifyMode.MENTIONS_AND_KEYWORDS
|
||||
val timeline = roomData.optJSONObject("timeline") ?: continue
|
||||
val events = timeline.optJSONArray("events") ?: continue
|
||||
|
||||
for (i in 0 until events.length()) {
|
||||
@@ -174,6 +199,8 @@ class MatrixSyncService : Service() {
|
||||
if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue
|
||||
|
||||
val content = event.optJSONObject("content") ?: continue
|
||||
if (mentionsOnly && !isSpecialMessage(content, notifyCtx)) continue
|
||||
|
||||
val msgtype = content.optString("msgtype")
|
||||
val rawBody = content.optString("body")
|
||||
val body = when (msgtype) {
|
||||
@@ -203,6 +230,144 @@ class MatrixSyncService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Load push rules, DM list, keywords, and own display name for notification filtering. */
|
||||
private fun loadNotifyContext(homeserver: String, token: String, myUserId: String): NotifyContext {
|
||||
val pushRules = fetchJson("$homeserver/_matrix/client/v3/pushrules/", token)
|
||||
val accountDataDirect = fetchJson("$homeserver/_matrix/client/v3/user/${urlEncode(myUserId)}/account_data/m.direct", token)
|
||||
val profile = fetchJson("$homeserver/_matrix/client/v3/profile/${urlEncode(myUserId)}", token)
|
||||
|
||||
val directRoomIds = mutableSetOf<String>()
|
||||
accountDataDirect?.keys()?.forEach { key ->
|
||||
val rooms = accountDataDirect.optJSONArray(key) ?: return@forEach
|
||||
for (i in 0 until rooms.length()) {
|
||||
rooms.optString(i).takeIf { it.isNotBlank() }?.let { directRoomIds.add(it) }
|
||||
}
|
||||
}
|
||||
|
||||
val keywords = mutableListOf<String>()
|
||||
val contentRules = pushRules?.optJSONObject("global")?.optJSONArray("content")
|
||||
if (contentRules != null) {
|
||||
for (i in 0 until contentRules.length()) {
|
||||
val rule = contentRules.optJSONObject(i) ?: continue
|
||||
if (rule.optBoolean("enabled", true).not()) continue
|
||||
if (!actionsIncludeNotify(rule.optJSONArray("actions"))) continue
|
||||
rule.optString("pattern").takeIf { it.isNotBlank() }?.let { keywords.add(it) }
|
||||
}
|
||||
}
|
||||
|
||||
return NotifyContext(
|
||||
myUserId = myUserId,
|
||||
myDisplayName = profile?.optString("displayname")?.takeIf { it.isNotBlank() },
|
||||
myLocalpart = myUserId.substringAfter("@").substringBefore(":").takeIf { it.isNotBlank() },
|
||||
keywords = keywords,
|
||||
directRoomIds = directRoomIds,
|
||||
pushRules = pushRules,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors Cinny/Paarrot room notification modes:
|
||||
* Mute override → mute; room notify → all; room dont_notify → mentions;
|
||||
* unset DM → all; unset room → mentions (app Default).
|
||||
*/
|
||||
private fun resolveRoomNotifyMode(roomId: String, ctx: NotifyContext): RoomNotifyMode {
|
||||
val global = ctx.pushRules?.optJSONObject("global")
|
||||
// If push rules are unavailable, rely on homeserver unread_notifications only.
|
||||
?: return RoomNotifyMode.ALL_MESSAGES
|
||||
|
||||
val overrides = global.optJSONArray("override")
|
||||
if (overrides != null) {
|
||||
for (i in 0 until overrides.length()) {
|
||||
val rule = overrides.optJSONObject(i) ?: continue
|
||||
if (rule.optString("rule_id") != roomId) continue
|
||||
if (rule.optBoolean("enabled", true).not()) continue
|
||||
if (!actionsIncludeNotify(rule.optJSONArray("actions"))) {
|
||||
return RoomNotifyMode.MUTE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val roomRules = global.optJSONArray("room")
|
||||
if (roomRules != null) {
|
||||
for (i in 0 until roomRules.length()) {
|
||||
val rule = roomRules.optJSONObject(i) ?: continue
|
||||
if (rule.optString("rule_id") != roomId) continue
|
||||
if (rule.optBoolean("enabled", true).not()) continue
|
||||
return if (actionsIncludeNotify(rule.optJSONArray("actions"))) {
|
||||
RoomNotifyMode.ALL_MESSAGES
|
||||
} else {
|
||||
RoomNotifyMode.MENTIONS_AND_KEYWORDS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return if (ctx.directRoomIds.contains(roomId)) {
|
||||
RoomNotifyMode.ALL_MESSAGES
|
||||
} else {
|
||||
RoomNotifyMode.MENTIONS_AND_KEYWORDS
|
||||
}
|
||||
}
|
||||
|
||||
private fun actionsIncludeNotify(actions: org.json.JSONArray?): Boolean {
|
||||
if (actions == null) return false
|
||||
for (i in 0 until actions.length()) {
|
||||
when (val action = actions.opt(i)) {
|
||||
is String -> if (action == "notify") return true
|
||||
is JSONObject -> if (action.has("set_tweak")) { /* tweaks only */ }
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun isSpecialMessage(content: JSONObject, ctx: NotifyContext): Boolean {
|
||||
val mentions = content.optJSONObject("m.mentions")
|
||||
if (mentions != null) {
|
||||
val userIds = mentions.optJSONArray("user_ids")
|
||||
if (userIds != null) {
|
||||
for (i in 0 until userIds.length()) {
|
||||
if (userIds.optString(i) == ctx.myUserId) return true
|
||||
}
|
||||
}
|
||||
if (mentions.optBoolean("room", false)) return true
|
||||
}
|
||||
|
||||
val body = content.optString("body").lowercase()
|
||||
if (body.isBlank()) return false
|
||||
if (body.contains("@room")) return true
|
||||
ctx.myDisplayName?.lowercase()?.takeIf { it.isNotBlank() }?.let {
|
||||
if (body.contains(it)) return true
|
||||
}
|
||||
ctx.myLocalpart?.lowercase()?.let {
|
||||
if (body.contains(it)) return true
|
||||
}
|
||||
for (keyword in ctx.keywords) {
|
||||
if (body.contains(keyword.lowercase())) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun urlEncode(value: String): String = URLEncoder.encode(value, "UTF-8")
|
||||
|
||||
private fun fetchJson(urlString: String, token: String): JSONObject? {
|
||||
return try {
|
||||
val conn = URL(urlString).openConnection() as HttpURLConnection
|
||||
try {
|
||||
conn.requestMethod = "GET"
|
||||
conn.setRequestProperty("Authorization", "Bearer $token")
|
||||
conn.setRequestProperty("Accept", "application/json")
|
||||
conn.connectTimeout = 4_000
|
||||
conn.readTimeout = 6_000
|
||||
if (conn.responseCode != 200) return null
|
||||
JSONObject(conn.inputStream.bufferedReader().readText())
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch $urlString: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveProfile(mxid: String, homeserver: String, token: String): UserProfile {
|
||||
val cachedName = displayNameCache[mxid]
|
||||
if (cachedName != null) return UserProfile(cachedName, avatarCache[mxid])
|
||||
|
||||
@@ -62,7 +62,6 @@ object ShareIntentStore {
|
||||
|
||||
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return
|
||||
|
||||
val receivedAt = System.currentTimeMillis()
|
||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
|
||||
|
||||
@@ -86,8 +85,29 @@ object ShareIntentStore {
|
||||
uri?.let { uris.add(it) }
|
||||
}
|
||||
|
||||
deliverShare(context, uris, text, subject)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver content URIs from the IME (Gboard GIF), clipboard paste, or drag-drop
|
||||
* into the same share pipeline used by ACTION_SEND.
|
||||
*/
|
||||
fun handleContentUris(context: Context, uris: List<Uri>) {
|
||||
if (uris.isEmpty()) return
|
||||
deliverShare(context, uris, text = null, subject = null)
|
||||
}
|
||||
|
||||
private fun deliverShare(
|
||||
context: Context,
|
||||
uris: List<Uri>,
|
||||
text: String?,
|
||||
subject: String?,
|
||||
) {
|
||||
val receivedAt = System.currentTimeMillis()
|
||||
val copiedFiles = uris.mapNotNull { copySharedUri(context, it, receivedAt) }
|
||||
|
||||
if (copiedFiles.isEmpty() && text.isNullOrBlank() && subject.isNullOrBlank()) return
|
||||
|
||||
val payload = SharePayload(
|
||||
text = text,
|
||||
subject = subject,
|
||||
|
||||
@@ -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: 9c7f71896c...b52926f7d8
@@ -1,3 +1,5 @@
|
||||
{
|
||||
"dependencies": {}
|
||||
"dependencies": {
|
||||
"@capacitor/status-bar": "8.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import React from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { MobileSwipeBackPanel } from './mobile/MobileSwipeBackPanel';
|
||||
import { useCompactNav, useIsCompactListRoute } from '../hooks/useCompactNav';
|
||||
import { MobileSwipeGestureHost } from './mobile/MobileSwipeGestureHost';
|
||||
|
||||
/**
|
||||
* Wrapper for Outlet that adds route-based animation
|
||||
* Forces remount on route change by using location as key
|
||||
* 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 (
|
||||
<MobileSwipeBackPanel>
|
||||
<MobileSwipeGestureHost>
|
||||
<div
|
||||
key={location.pathname}
|
||||
data-route-transition="true"
|
||||
{...(!compact && { 'data-route-transition': 'true' })}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
@@ -25,6 +35,6 @@ export function AnimatedOutlet() {
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</MobileSwipeBackPanel>
|
||||
</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,
|
||||
});
|
||||
@@ -1,180 +0,0 @@
|
||||
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCompactNav } from '../../hooks/useCompactNav';
|
||||
import { useBackRoute } from '../../hooks/useBackRoute';
|
||||
import * as css from './mobile-gestures.css';
|
||||
|
||||
const COMMIT_RATIO = 0.28;
|
||||
const MIN_COMMIT_PX = 72;
|
||||
const MAX_START_Y_RATIO = 0.88;
|
||||
|
||||
type DragState = {
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
dragging: boolean;
|
||||
moved: boolean;
|
||||
};
|
||||
|
||||
type MobileSwipeBackPanelProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function MobileSwipeBackPanel({ children }: MobileSwipeBackPanelProps) {
|
||||
const compact = useCompactNav();
|
||||
const { canGoBack, goBack } = useBackRoute();
|
||||
const enabled = compact && canGoBack;
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
|
||||
const resetTransform = useCallback((animate = true) => {
|
||||
const content = contentRef.current;
|
||||
if (!content) return;
|
||||
setAnimating(animate);
|
||||
setOffset(0);
|
||||
content.style.transition = animate ? 'transform 0.22s cubic-bezier(0.4, 0, 0.2, 1)' : 'none';
|
||||
content.style.transform = 'translateX(0px)';
|
||||
}, []);
|
||||
|
||||
const setTransform = useCallback((px: number, animate = false) => {
|
||||
const content = contentRef.current;
|
||||
if (!content) return;
|
||||
setOffset(px);
|
||||
content.style.transition = animate
|
||||
? 'transform 0.22s cubic-bezier(0.4, 0, 0.2, 1)'
|
||||
: 'none';
|
||||
content.style.transform = `translateX(${px}px)`;
|
||||
}, []);
|
||||
|
||||
const commitBack = useCallback(() => {
|
||||
const width = rootRef.current?.clientWidth ?? window.innerWidth;
|
||||
setAnimating(true);
|
||||
setTransform(width, true);
|
||||
window.setTimeout(() => {
|
||||
goBack();
|
||||
resetTransform(false);
|
||||
setAnimating(false);
|
||||
}, 180);
|
||||
}, [goBack, resetTransform, setTransform]);
|
||||
|
||||
const shouldIgnoreTarget = (target: EventTarget | null): boolean => {
|
||||
if (!(target instanceof Element)) return true;
|
||||
return Boolean(
|
||||
target.closest(
|
||||
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], button, a, [role="button"], [data-carousel-scroller], [data-disable-swipe-back="true"]'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!enabled || animating || evt.button !== 0 || !evt.isPrimary) return;
|
||||
if (shouldIgnoreTarget(evt.target)) return;
|
||||
if (evt.clientY > window.innerHeight * MAX_START_Y_RATIO) return;
|
||||
|
||||
dragRef.current = {
|
||||
pointerId: evt.pointerId,
|
||||
startX: evt.clientX,
|
||||
startY: evt.clientY,
|
||||
dragging: true,
|
||||
moved: false,
|
||||
};
|
||||
|
||||
rootRef.current?.setPointerCapture(evt.pointerId);
|
||||
},
|
||||
[animating, enabled]
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || !drag.dragging || drag.pointerId !== evt.pointerId) return;
|
||||
|
||||
const deltaX = evt.clientX - drag.startX;
|
||||
const deltaY = evt.clientY - drag.startY;
|
||||
|
||||
if (!drag.moved) {
|
||||
if (Math.abs(deltaX) < 8 && Math.abs(deltaY) < 8) return;
|
||||
if (Math.abs(deltaY) > Math.abs(deltaX)) {
|
||||
dragRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (deltaX <= 0) {
|
||||
dragRef.current = null;
|
||||
return;
|
||||
}
|
||||
drag.moved = true;
|
||||
}
|
||||
|
||||
evt.preventDefault();
|
||||
const width = rootRef.current?.clientWidth ?? window.innerWidth;
|
||||
setTransform(Math.min(Math.max(deltaX, 0), width), false);
|
||||
},
|
||||
[setTransform]
|
||||
);
|
||||
|
||||
const endDrag = useCallback(
|
||||
(pointerId: number) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== pointerId) return;
|
||||
|
||||
if (rootRef.current?.hasPointerCapture(pointerId)) {
|
||||
rootRef.current.releasePointerCapture(pointerId);
|
||||
}
|
||||
|
||||
dragRef.current = null;
|
||||
|
||||
if (!drag.moved) {
|
||||
resetTransform(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const width = rootRef.current?.clientWidth ?? window.innerWidth;
|
||||
const shouldCommit = offset >= Math.max(width * COMMIT_RATIO, MIN_COMMIT_PX);
|
||||
if (shouldCommit) {
|
||||
commitBack();
|
||||
return;
|
||||
}
|
||||
|
||||
resetTransform(true);
|
||||
},
|
||||
[commitBack, offset, resetTransform]
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
endDrag(evt.pointerId);
|
||||
},
|
||||
[endDrag]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
resetTransform(false);
|
||||
}, [enabled, resetTransform]);
|
||||
|
||||
if (!enabled) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={css.SwipeBackRoot}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
>
|
||||
<div className={css.SwipeBackUnderlay} aria-hidden>
|
||||
<div className={css.SwipeBackSidebarPeek} />
|
||||
<div className={css.SwipeBackChannelPeek} />
|
||||
</div>
|
||||
<div ref={contentRef} className={css.SwipeBackContent}>
|
||||
{children}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,13 @@
|
||||
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||
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 { startReplyToEvent } from '../../features/room/replyToMessage';
|
||||
import { mobileSwipeReplyBridgeRef } from './mobileSwipeReplyBridge';
|
||||
import * as css from './mobile-gestures.css';
|
||||
|
||||
const SWIPE_THRESHOLD = 56;
|
||||
const MAX_SWIPE = 88;
|
||||
|
||||
type DragState = {
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
messageEl: HTMLElement;
|
||||
messageId: string;
|
||||
moved: boolean;
|
||||
};
|
||||
|
||||
type MobileSwipeToReplyLayerProps = {
|
||||
room: Room;
|
||||
editor: Editor;
|
||||
@@ -28,178 +16,40 @@ type MobileSwipeToReplyLayerProps = {
|
||||
|
||||
export function MobileSwipeToReplyLayer({ room, editor, children }: MobileSwipeToReplyLayerProps) {
|
||||
const compact = useCompactNav();
|
||||
const enabled = compact;
|
||||
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
|
||||
const layerRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const [indicatorTop, setIndicatorTop] = useState<number | null>(null);
|
||||
const [indicatorActive, setIndicatorActive] = useState(false);
|
||||
|
||||
const clearMessageTransform = useCallback((el?: HTMLElement | null, animate = true) => {
|
||||
const target = el ?? dragRef.current?.messageEl;
|
||||
if (!target) return;
|
||||
target.style.transition = animate ? 'transform 0.18s cubic-bezier(0.4, 0, 0.2, 1)' : 'none';
|
||||
target.style.transform = 'translateX(0px)';
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
if (!compact) return;
|
||||
|
||||
const resetGesture = useCallback(() => {
|
||||
clearMessageTransform();
|
||||
dragRef.current = null;
|
||||
setIndicatorTop(null);
|
||||
setIndicatorActive(false);
|
||||
}, [clearMessageTransform]);
|
||||
mobileSwipeReplyBridgeRef.current = {
|
||||
room,
|
||||
editor,
|
||||
setReplyDraft,
|
||||
layerEl: layerRef.current,
|
||||
setIndicator: (top, active) => {
|
||||
setIndicatorTop(top);
|
||||
setIndicatorActive(active);
|
||||
},
|
||||
};
|
||||
|
||||
const shouldIgnoreTarget = (target: EventTarget | null): boolean => {
|
||||
if (!(target instanceof Element)) return true;
|
||||
if (
|
||||
target.closest(
|
||||
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], button, a, [role="button"], [data-disable-swipe-reply="true"]'
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let el: Element | null = target;
|
||||
while (el && layerRef.current?.contains(el)) {
|
||||
if (el instanceof HTMLElement) {
|
||||
const { overflowX } = window.getComputedStyle(el);
|
||||
if (
|
||||
(overflowX === 'auto' || overflowX === 'scroll') &&
|
||||
el.scrollWidth > el.clientWidth + 8
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return () => {
|
||||
if (mobileSwipeReplyBridgeRef.current?.room.roomId === room.roomId) {
|
||||
mobileSwipeReplyBridgeRef.current = null;
|
||||
}
|
||||
el = el.parentElement;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const findMessageElement = (target: EventTarget | null): HTMLElement | null => {
|
||||
if (!(target instanceof Element)) return null;
|
||||
return target.closest('[data-message-id]') as HTMLElement | null;
|
||||
};
|
||||
|
||||
const updateIndicator = useCallback((messageEl: HTMLElement, offset: number) => {
|
||||
const layer = layerRef.current;
|
||||
if (!layer) return;
|
||||
const messageRect = messageEl.getBoundingClientRect();
|
||||
const layerRect = layer.getBoundingClientRect();
|
||||
setIndicatorTop(messageRect.top - layerRect.top + messageRect.height / 2 - 18);
|
||||
setIndicatorActive(Math.abs(offset) >= SWIPE_THRESHOLD * 0.65);
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!enabled || evt.button !== 0 || !evt.isPrimary) return;
|
||||
if (shouldIgnoreTarget(evt.target)) return;
|
||||
|
||||
const messageEl = findMessageElement(evt.target);
|
||||
const messageId = messageEl?.getAttribute('data-message-id');
|
||||
if (!messageEl || !messageId) return;
|
||||
|
||||
dragRef.current = {
|
||||
pointerId: evt.pointerId,
|
||||
startX: evt.clientX,
|
||||
startY: evt.clientY,
|
||||
messageEl,
|
||||
messageId,
|
||||
moved: false,
|
||||
};
|
||||
|
||||
layerRef.current?.setPointerCapture(evt.pointerId);
|
||||
},
|
||||
[enabled]
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== evt.pointerId) return;
|
||||
|
||||
const deltaX = evt.clientX - drag.startX;
|
||||
const deltaY = evt.clientY - drag.startY;
|
||||
|
||||
if (!drag.moved) {
|
||||
if (Math.abs(deltaX) < 8 && Math.abs(deltaY) < 8) return;
|
||||
if (Math.abs(deltaY) > Math.abs(deltaX)) {
|
||||
resetGesture();
|
||||
return;
|
||||
}
|
||||
if (deltaX >= 0) {
|
||||
resetGesture();
|
||||
return;
|
||||
}
|
||||
drag.moved = true;
|
||||
}
|
||||
|
||||
evt.preventDefault();
|
||||
const offset = Math.max(deltaX, -MAX_SWIPE);
|
||||
drag.messageEl.style.transition = 'none';
|
||||
drag.messageEl.style.transform = `translateX(${offset}px)`;
|
||||
updateIndicator(drag.messageEl, offset);
|
||||
},
|
||||
[resetGesture, updateIndicator]
|
||||
);
|
||||
|
||||
const endDrag = useCallback(
|
||||
(pointerId: number) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== pointerId) return;
|
||||
|
||||
if (layerRef.current?.hasPointerCapture(pointerId)) {
|
||||
layerRef.current.releasePointerCapture(pointerId);
|
||||
}
|
||||
|
||||
const { messageEl, messageId, moved } = drag;
|
||||
dragRef.current = null;
|
||||
|
||||
if (!moved) {
|
||||
resetGesture();
|
||||
return;
|
||||
}
|
||||
|
||||
const matrix = window.getComputedStyle(messageEl).transform;
|
||||
const offset =
|
||||
matrix && matrix !== 'none'
|
||||
? Number(new DOMMatrix(matrix).m41)
|
||||
: 0;
|
||||
|
||||
const shouldReply = Math.abs(offset) >= SWIPE_THRESHOLD;
|
||||
clearMessageTransform(messageEl, true);
|
||||
setIndicatorTop(null);
|
||||
setIndicatorActive(false);
|
||||
};
|
||||
}, [compact, editor, room, setReplyDraft]);
|
||||
|
||||
if (shouldReply) {
|
||||
startReplyToEvent(room, messageId, setReplyDraft, editor);
|
||||
}
|
||||
},
|
||||
[clearMessageTransform, editor, resetGesture, room, setReplyDraft]
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
endDrag(evt.pointerId);
|
||||
},
|
||||
[endDrag]
|
||||
);
|
||||
|
||||
useEffect(() => resetGesture, [room.roomId, resetGesture]);
|
||||
|
||||
if (!enabled) {
|
||||
if (!compact) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={layerRef}
|
||||
className={css.SwipeToReplyLayer}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
>
|
||||
<div ref={layerRef} className={css.SwipeToReplyLayer}>
|
||||
{children}
|
||||
{indicatorTop !== null && (
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
export const SwipeBackRoot = style({
|
||||
@@ -11,38 +11,56 @@ export const SwipeBackRoot = style({
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
export const SwipeBackUnderlay = style({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
});
|
||||
|
||||
export const SwipeBackSidebarPeek = style({
|
||||
width: toRem(66),
|
||||
flexShrink: 0,
|
||||
backgroundColor: color.Background.Container,
|
||||
borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
|
||||
});
|
||||
|
||||
export const SwipeBackChannelPeek = style({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
backgroundColor: color.Background.Container,
|
||||
});
|
||||
|
||||
export const SwipeBackContent = style({
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: color.Background.Container,
|
||||
boxShadow: '0 0 24px rgba(0, 0, 0, 0.35)',
|
||||
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({
|
||||
@@ -51,7 +69,6 @@ export const SwipeToReplyLayer = style({
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
touchAction: 'pan-y',
|
||||
});
|
||||
|
||||
export const SwipeToReplyIndicator = style({
|
||||
|
||||
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,
|
||||
};
|
||||
214
overlay/src/app/components/page/Page.tsx
Normal file
214
overlay/src/app/components/page/Page.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
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';
|
||||
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||
|
||||
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'}
|
||||
data-page-nav=""
|
||||
>
|
||||
<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"
|
||||
data-folder-tab="nav"
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
export function PageNavContent({
|
||||
scrollRef,
|
||||
scrollProps,
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
scrollRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
scrollProps?: React.ComponentProps<typeof Scroll>;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const hideScrollbar = isStationeryTheme(theme);
|
||||
|
||||
return (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Scroll
|
||||
ref={scrollRef}
|
||||
variant="Background"
|
||||
direction="Vertical"
|
||||
size={hideScrollbar ? '0' : '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)}
|
||||
data-page=""
|
||||
{...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)}
|
||||
data-folder-tab-bar=""
|
||||
{...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} />
|
||||
));
|
||||
136
overlay/src/app/features/forum/ForumBoardDetail.tsx
Normal file
136
overlay/src/app/features/forum/ForumBoardDetail.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Box, Scroll, Text, color } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import classNames from 'classnames';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useCompactNav } from '../../hooks/useCompactNav';
|
||||
import { Page, PageContent } from '../../components/page';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
||||
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
|
||||
import { getSpacePath } from '../../pages/pathUtils';
|
||||
import { useForumBoardContext } from './ForumBoardContext';
|
||||
import { ForumThreadDetail } from './ForumThreadDetail';
|
||||
import type { ForumPost } from './types';
|
||||
import * as theme from './forumTheme.css';
|
||||
|
||||
/** Thread detail in the main pane; post list lives in ForumFeedSidebar. */
|
||||
export function ForumBoardDetail() {
|
||||
const mx = useMatrixClient();
|
||||
const navigate = useNavigate();
|
||||
const compact = useCompactNav();
|
||||
const [optimisticPost, setOptimisticPost] = useState<ForumPost | null>(null);
|
||||
|
||||
const {
|
||||
forumSpace,
|
||||
query,
|
||||
error,
|
||||
selectedPostId,
|
||||
activeTopicRoomId,
|
||||
threads,
|
||||
recordThreadReply,
|
||||
} = useForumBoardContext();
|
||||
|
||||
// Compact: /feed/ without a selected post has no detail content — return to the list
|
||||
// so an empty detail layer cannot cover the forum sidebar.
|
||||
useEffect(() => {
|
||||
if (!compact || selectedPostId) return;
|
||||
navigate(getSpacePath(getCanonicalAliasOrRoomId(mx, forumSpace.roomId)), { replace: true });
|
||||
}, [compact, selectedPostId, navigate, mx, forumSpace.roomId]);
|
||||
|
||||
const activeTopicRoom = activeTopicRoomId ? mx.getRoom(activeTopicRoomId) : null;
|
||||
const topicSelected = Boolean(query.topic);
|
||||
|
||||
const setActiveThreadId = useSetAtom(
|
||||
activeThreadIdAtomFamily(activeTopicRoomId ?? forumSpace.roomId)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPostId && activeTopicRoomId) {
|
||||
setActiveThreadId(selectedPostId);
|
||||
return () => setActiveThreadId(undefined);
|
||||
}
|
||||
setActiveThreadId(undefined);
|
||||
return undefined;
|
||||
}, [selectedPostId, activeTopicRoomId, setActiveThreadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (optimisticPost && optimisticPost.eventId !== selectedPostId) {
|
||||
setOptimisticPost(null);
|
||||
}
|
||||
}, [optimisticPost, selectedPostId]);
|
||||
|
||||
const selectedThread = useMemo(
|
||||
() => threads.find((t) => t.eventId === selectedPostId),
|
||||
[threads, selectedPostId]
|
||||
);
|
||||
|
||||
const handleReplySent = useCallback(
|
||||
(_reply: ForumPost) => {
|
||||
if (selectedPostId) {
|
||||
recordThreadReply(selectedPostId);
|
||||
}
|
||||
},
|
||||
[recordThreadReply, selectedPostId]
|
||||
);
|
||||
|
||||
const showThread = Boolean(selectedPostId && activeTopicRoom);
|
||||
const threadInitialPost =
|
||||
optimisticPost?.eventId === selectedPostId ? optimisticPost : null;
|
||||
const detailDropRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<Page className={classNames(ContainerColor({ variant: 'Background' }), theme.ForumAppRoot)}>
|
||||
<PageContent className={theme.ForumPageContent}>
|
||||
{error && (
|
||||
<Text as="p" style={{ color: color.Critical.Main, padding: '0.5rem 1rem' }}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<aside className={theme.ForumDetailPane}>
|
||||
{showThread && activeTopicRoom ? (
|
||||
<div ref={detailDropRef} className={theme.ForumTopicPostDetailThreadLayout}>
|
||||
<ForumThreadDetail
|
||||
roomId={activeTopicRoom.roomId}
|
||||
rootEventId={selectedPostId!}
|
||||
titleHint={selectedThread?.title}
|
||||
initialPost={threadInitialPost}
|
||||
fileDropContainerRef={detailDropRef}
|
||||
onReplySent={handleReplySent}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Scroll
|
||||
id="topicPostDetail"
|
||||
className={theme.ForumTopicPostDetailScroll}
|
||||
variant="Surface"
|
||||
direction="Vertical"
|
||||
size="300"
|
||||
hideTrack
|
||||
visibility="Hover"
|
||||
>
|
||||
<div className={theme.ForumTopicPostDetailInner}>
|
||||
<Box
|
||||
className={theme.ForumEmptyDetail}
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
gap="300"
|
||||
>
|
||||
<Icon src={Icons.Thread} size="600" />
|
||||
<Text size="T300" priority="300">
|
||||
{topicSelected
|
||||
? 'Select a post to read the thread'
|
||||
: 'Pick a category and topic, then choose a post'}
|
||||
</Text>
|
||||
</Box>
|
||||
</div>
|
||||
</Scroll>
|
||||
)}
|
||||
</aside>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
431
overlay/src/app/features/forum/useForumBoard.ts
Normal file
431
overlay/src/app/features/forum/useForumBoard.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||
import { useCompactNav } from '../../hooks/useCompactNav';
|
||||
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
|
||||
import { getSpaceFeedPath, getSpaceLobbyPath, getSpacePath } from '../../pages/pathUtils';
|
||||
import { createRoomModalAtom } from '../../state/createRoomModal';
|
||||
import { createSpaceModalAtom } from '../../state/createSpaceModal';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import {
|
||||
buildForumSections,
|
||||
buildTopicThreads,
|
||||
buildThreadSummaries,
|
||||
} from './forumTopicHelpers';
|
||||
import { applyTopicStatusSort } from './topicSearch';
|
||||
import {
|
||||
createForumPostFromPayload,
|
||||
listForumTopics,
|
||||
listTopicFeedPosts,
|
||||
loadAggregatedTopicFeed,
|
||||
} from './forumFeed';
|
||||
import type {
|
||||
ForumBoardQuery,
|
||||
ForumPost,
|
||||
ForumPublishedPost,
|
||||
ForumSection,
|
||||
ForumThreadSummary,
|
||||
} from './types';
|
||||
import { useForumRoomLiveUpdates } from './useForumRoomLiveUpdates';
|
||||
|
||||
export type ForumBoardScope = {
|
||||
forumSpaceId: string;
|
||||
/** When set, only show posts from this topic room. */
|
||||
topicRoomId?: string;
|
||||
};
|
||||
|
||||
function scopesFromSelection(
|
||||
sections: ForumSection[],
|
||||
category?: string,
|
||||
topic?: string
|
||||
): Array<{ roomId: string; sectionTitle?: string; topicName?: string }> {
|
||||
if (topic) {
|
||||
for (const section of sections) {
|
||||
const match = section.topics.find((t) => t.roomId === topic);
|
||||
if (match) {
|
||||
return [{ roomId: match.roomId, sectionTitle: section.title, topicName: match.name }];
|
||||
}
|
||||
}
|
||||
return [{ roomId: topic }];
|
||||
}
|
||||
|
||||
if (category) {
|
||||
const section = sections.find((s) => s.title === category);
|
||||
if (!section) return [];
|
||||
return section.topics.map((t) => ({
|
||||
roomId: t.roomId,
|
||||
sectionTitle: section.title,
|
||||
topicName: t.name,
|
||||
}));
|
||||
}
|
||||
|
||||
return sections.flatMap((section) =>
|
||||
section.topics.map((t) => ({
|
||||
roomId: t.roomId,
|
||||
sectionTitle: section.title,
|
||||
topicName: t.name,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export function useForumBoard(scope: ForumBoardScope, forumSpace: Room) {
|
||||
const mx = useMatrixClient();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const selectedRoomId = useSelectedRoom();
|
||||
const compact = useCompactNav();
|
||||
|
||||
const searchParams = useMemo(
|
||||
() => new URLSearchParams(location.search),
|
||||
[location.search]
|
||||
);
|
||||
|
||||
const filterQuery: ForumBoardQuery = useMemo(
|
||||
() => ({
|
||||
category: searchParams.get('category') || undefined,
|
||||
topic: searchParams.get('topic') || undefined,
|
||||
q: searchParams.get('q') || undefined,
|
||||
status: (searchParams.get('status') as ForumBoardQuery['status']) || 'all',
|
||||
sort: (searchParams.get('sort') as ForumBoardQuery['sort']) || 'hot',
|
||||
}),
|
||||
[searchParams]
|
||||
);
|
||||
|
||||
const query: ForumBoardQuery = useMemo(
|
||||
() => ({
|
||||
...filterQuery,
|
||||
post: searchParams.get('post') || undefined,
|
||||
postRoom: searchParams.get('postRoom') || undefined,
|
||||
}),
|
||||
[filterQuery, searchParams]
|
||||
);
|
||||
|
||||
const [sections, setSections] = useState<ForumSection[]>([]);
|
||||
const [threads, setThreads] = useState<ForumThreadSummary[]>([]);
|
||||
const [loadingSections, setLoadingSections] = useState(true);
|
||||
const [loadingThreads, setLoadingThreads] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [postsNextBatch, setPostsNextBatch] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const applyForumSearchParams = useCallback(
|
||||
(mutate: (next: URLSearchParams) => void) => {
|
||||
const next = new URLSearchParams(location.search);
|
||||
mutate(next);
|
||||
const search = next.toString();
|
||||
const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, forumSpace.roomId);
|
||||
|
||||
const onTopicRoomRoute =
|
||||
Boolean(selectedRoomId) && selectedRoomId !== forumSpace.roomId;
|
||||
|
||||
// Compact master view only mounts the post list on /:spaceId/. Opening a post
|
||||
// must switch to /feed/ so ForumFeedPage mounts as the detail pane.
|
||||
let pathname = location.pathname;
|
||||
if (onTopicRoomRoute) {
|
||||
pathname = getSpaceLobbyPath(spaceIdOrAlias);
|
||||
} else if (next.get('post')) {
|
||||
pathname = getSpaceFeedPath(spaceIdOrAlias);
|
||||
} else if (compact) {
|
||||
pathname = getSpacePath(spaceIdOrAlias);
|
||||
}
|
||||
|
||||
navigate(
|
||||
{
|
||||
pathname,
|
||||
search: search ? `?${search}` : '',
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
},
|
||||
[
|
||||
location.pathname,
|
||||
location.search,
|
||||
navigate,
|
||||
mx,
|
||||
forumSpace.roomId,
|
||||
selectedRoomId,
|
||||
compact,
|
||||
]
|
||||
);
|
||||
|
||||
const setQueryParam = useCallback(
|
||||
(key: string, value: string | null) => {
|
||||
applyForumSearchParams((next) => {
|
||||
if (!value) next.delete(key);
|
||||
else next.set(key, value);
|
||||
if (key !== 'post') {
|
||||
next.delete('post');
|
||||
next.delete('postRoom');
|
||||
}
|
||||
});
|
||||
},
|
||||
[applyForumSearchParams]
|
||||
);
|
||||
|
||||
const setCategory = useCallback(
|
||||
(category: string | null) => {
|
||||
applyForumSearchParams((next) => {
|
||||
if (!category) next.delete('category');
|
||||
else next.set('category', category);
|
||||
next.delete('topic');
|
||||
next.delete('post');
|
||||
next.delete('postRoom');
|
||||
});
|
||||
},
|
||||
[applyForumSearchParams]
|
||||
);
|
||||
|
||||
const loadSections = useCallback(async () => {
|
||||
setLoadingSections(true);
|
||||
setError(null);
|
||||
try {
|
||||
const topics = await listForumTopics(mx, scope.forumSpaceId);
|
||||
setSections(buildForumSections(topics));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load forum categories');
|
||||
} finally {
|
||||
setLoadingSections(false);
|
||||
}
|
||||
}, [mx, scope.forumSpaceId]);
|
||||
|
||||
const refreshThreads = useCallback(async (options?: { silent?: boolean }) => {
|
||||
if (!options?.silent) setLoadingThreads(true);
|
||||
setError(null);
|
||||
try {
|
||||
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
|
||||
if (roomScopes.length === 0) {
|
||||
setThreads([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let posts;
|
||||
if (roomScopes.length === 1) {
|
||||
const result = await listTopicFeedPosts(mx, roomScopes[0].roomId);
|
||||
posts = result.posts;
|
||||
setPostsNextBatch(result.nextBatch);
|
||||
const scopeMeta = roomScopes[0];
|
||||
posts.forEach((post) => {
|
||||
post.topicRoomId = scopeMeta.roomId;
|
||||
if (scopeMeta.sectionTitle) post.sectionTitle = scopeMeta.sectionTitle;
|
||||
if (scopeMeta.topicName) post.topicName = scopeMeta.topicName;
|
||||
});
|
||||
} else {
|
||||
posts = await loadAggregatedTopicFeed(mx, roomScopes);
|
||||
setPostsNextBatch(null);
|
||||
}
|
||||
|
||||
setThreads(buildTopicThreads(posts, filterQuery));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load forum posts');
|
||||
} finally {
|
||||
if (!options?.silent) setLoadingThreads(false);
|
||||
}
|
||||
}, [mx, sections, filterQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSections();
|
||||
}, [loadSections]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSpaceChild = (event: MatrixEvent) => {
|
||||
if (event.getType() !== StateEvent.SpaceChild) return;
|
||||
const roomId = event.getRoomId();
|
||||
if (!roomId) return;
|
||||
if (
|
||||
roomId === forumSpace.roomId ||
|
||||
sections.some((section) => section.spaceId === roomId)
|
||||
) {
|
||||
loadSections();
|
||||
}
|
||||
};
|
||||
mx.on(RoomStateEvent.Events, handleSpaceChild);
|
||||
return () => {
|
||||
mx.removeListener(RoomStateEvent.Events, handleSpaceChild);
|
||||
};
|
||||
}, [mx, forumSpace.roomId, sections, loadSections]);
|
||||
|
||||
const createSpaceModal = useAtomValue(createSpaceModalAtom);
|
||||
const createRoomModal = useAtomValue(createRoomModalAtom);
|
||||
const prevCreateSpaceModal = useRef(createSpaceModal);
|
||||
const prevCreateRoomModal = useRef(createRoomModal);
|
||||
useEffect(() => {
|
||||
if (prevCreateSpaceModal.current && !createSpaceModal) {
|
||||
loadSections();
|
||||
}
|
||||
if (prevCreateRoomModal.current && !createRoomModal) {
|
||||
loadSections();
|
||||
}
|
||||
prevCreateSpaceModal.current = createSpaceModal;
|
||||
prevCreateRoomModal.current = createRoomModal;
|
||||
}, [createSpaceModal, createRoomModal, loadSections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadingSections) return;
|
||||
void refreshThreads();
|
||||
}, [
|
||||
loadingSections,
|
||||
refreshThreads,
|
||||
filterQuery.category,
|
||||
filterQuery.topic,
|
||||
filterQuery.q,
|
||||
filterQuery.status,
|
||||
filterQuery.sort,
|
||||
]);
|
||||
|
||||
const scopedRooms = useMemo(() => {
|
||||
if (loadingSections) return [];
|
||||
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
|
||||
return roomScopes
|
||||
.map((scope) => mx.getRoom(scope.roomId))
|
||||
.filter((room): room is Room => room !== null);
|
||||
}, [loadingSections, sections, filterQuery.category, filterQuery.topic, mx]);
|
||||
|
||||
useForumRoomLiveUpdates(scopedRooms, () => {
|
||||
void refreshThreads({ silent: true });
|
||||
});
|
||||
|
||||
const selectedPostId = query.post ?? null;
|
||||
const selectedThread = threads.find((t) => t.eventId === selectedPostId);
|
||||
const activeTopicRoomId =
|
||||
query.postRoom || selectedThread?.topicRoomId || query.topic || scope.topicRoomId || null;
|
||||
|
||||
const selectPost = useCallback(
|
||||
(eventId: string | null, topicRoomId?: string) => {
|
||||
applyForumSearchParams((next) => {
|
||||
if (!eventId) {
|
||||
next.delete('post');
|
||||
next.delete('postRoom');
|
||||
} else {
|
||||
next.set('post', eventId);
|
||||
if (topicRoomId) next.set('postRoom', topicRoomId);
|
||||
else next.delete('postRoom');
|
||||
}
|
||||
});
|
||||
},
|
||||
[applyForumSearchParams]
|
||||
);
|
||||
|
||||
const insertThreadFromNewPost = useCallback(
|
||||
(published: ForumPublishedPost): ForumPost | null => {
|
||||
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
|
||||
const inScope =
|
||||
filterQuery.topic === published.topicRoomId ||
|
||||
(!filterQuery.topic &&
|
||||
(filterQuery.category
|
||||
? roomScopes.some((s) => s.roomId === published.topicRoomId)
|
||||
: true));
|
||||
|
||||
const scopeMeta = roomScopes.find((s) => s.roomId === published.topicRoomId);
|
||||
const post = createForumPostFromPayload(
|
||||
mx,
|
||||
published.topicRoomId,
|
||||
published.eventId,
|
||||
{
|
||||
title: published.title,
|
||||
plainText: published.plainText,
|
||||
formattedHtml: published.formattedHtml,
|
||||
},
|
||||
{
|
||||
topicRoomId: published.topicRoomId,
|
||||
sectionTitle: scopeMeta?.sectionTitle,
|
||||
topicName: scopeMeta?.topicName,
|
||||
}
|
||||
);
|
||||
|
||||
if (inScope) {
|
||||
const [summary] = buildThreadSummaries([post]);
|
||||
if (summary) {
|
||||
setThreads((prev) => {
|
||||
const without = prev.filter((t) => t.eventId !== summary.eventId);
|
||||
const merged = [summary, ...without];
|
||||
return applyTopicStatusSort(merged, filterQuery.status || 'all', filterQuery.sort || 'hot');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return post;
|
||||
},
|
||||
[mx, sections, filterQuery]
|
||||
);
|
||||
|
||||
const recordThreadReply = useCallback((rootEventId: string) => {
|
||||
setThreads((prev) =>
|
||||
prev.map((t) =>
|
||||
t.eventId === rootEventId
|
||||
? {
|
||||
...t,
|
||||
totalReplies: t.totalReplies + 1,
|
||||
lastActivityTs: Date.now(),
|
||||
}
|
||||
: t
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const loadMorePosts = useCallback(async () => {
|
||||
if (!postsNextBatch || loadingMore) return;
|
||||
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
|
||||
if (roomScopes.length !== 1) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
setError(null);
|
||||
try {
|
||||
const scopeMeta = roomScopes[0];
|
||||
const result = await listTopicFeedPosts(mx, scopeMeta.roomId, {
|
||||
from: postsNextBatch,
|
||||
minRoots: 1,
|
||||
});
|
||||
setPostsNextBatch(result.nextBatch);
|
||||
result.posts.forEach((post) => {
|
||||
post.topicRoomId = scopeMeta.roomId;
|
||||
if (scopeMeta.sectionTitle) post.sectionTitle = scopeMeta.sectionTitle;
|
||||
if (scopeMeta.topicName) post.topicName = scopeMeta.topicName;
|
||||
});
|
||||
setThreads((prev) => {
|
||||
const existingIds = new Set(prev.map((t) => t.eventId));
|
||||
const added = buildTopicThreads(result.posts, filterQuery).filter(
|
||||
(t) => !existingIds.has(t.eventId)
|
||||
);
|
||||
const merged = [...prev, ...added];
|
||||
const sort = filterQuery.sort || 'hot';
|
||||
merged.sort((a, b) => {
|
||||
if (sort === 'new') return b.timestamp - a.timestamp;
|
||||
if (sort === 'top') return b.totalReplies - a.totalReplies;
|
||||
const hotA = a.totalReplies * 4 + Math.floor((a.lastActivityTs - a.timestamp) / 3600000);
|
||||
const hotB = b.totalReplies * 4 + Math.floor((b.lastActivityTs - b.timestamp) / 3600000);
|
||||
return hotB - hotA;
|
||||
});
|
||||
return merged;
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load older posts');
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [postsNextBatch, loadingMore, sections, filterQuery, mx]);
|
||||
|
||||
return {
|
||||
forumSpace,
|
||||
sections,
|
||||
threads,
|
||||
query,
|
||||
loadingSections,
|
||||
loadingThreads,
|
||||
error,
|
||||
selectedPostId,
|
||||
selectedThread,
|
||||
activeTopicRoomId,
|
||||
setQueryParam,
|
||||
setCategory,
|
||||
selectPost,
|
||||
refreshThreads,
|
||||
insertThreadFromNewPost,
|
||||
recordThreadReply,
|
||||
loadMorePosts,
|
||||
hasMorePosts: Boolean(postsNextBatch),
|
||||
loadingMore,
|
||||
};
|
||||
}
|
||||
@@ -26,6 +26,8 @@ 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 => {
|
||||
@@ -63,6 +65,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
||||
const roomViewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const { immersive } = useMobileKeyboardLayout();
|
||||
|
||||
const { roomId } = room;
|
||||
const editor = useEditor();
|
||||
@@ -101,9 +104,13 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
||||
)
|
||||
);
|
||||
|
||||
const flushComposer = immersive;
|
||||
const composerPadding = flushComposer ? '0' : `0 ${config.space.S400}`;
|
||||
const showHeader = !immersive;
|
||||
|
||||
return (
|
||||
<Page ref={roomViewRef}>
|
||||
<RoomViewHeader />
|
||||
{showHeader && <RoomViewHeader />}
|
||||
{activeThreadId ? (
|
||||
<ThreadView room={room} threadRootId={activeThreadId} />
|
||||
) : (
|
||||
@@ -120,8 +127,21 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
||||
<RoomViewTyping room={room} />
|
||||
</Box>
|
||||
</MobileSwipeToReplyLayer>
|
||||
<Box shrink="No" direction="Column" data-disable-swipe-back="true">
|
||||
<div style={{ padding: `0 ${config.space.S400}` }}>
|
||||
<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}
|
||||
@@ -151,7 +171,12 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
|
||||
{!immersive &&
|
||||
(hideActivity ? (
|
||||
<RoomViewFollowingPlaceholder />
|
||||
) : (
|
||||
<RoomViewFollowing room={room} />
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
1406
overlay/src/app/features/room/message/Message.tsx
Normal file
1406
overlay/src/app/features/room/message/Message.tsx
Normal file
File diff suppressed because it is too large
Load Diff
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
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;
|
||||
}
|
||||
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 { useAtom, useSetAtom } from 'jotai';
|
||||
import { useAtomValue, useStore } from 'jotai';
|
||||
import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||
@@ -23,13 +22,12 @@ import {
|
||||
guessPerfectParent,
|
||||
} from '../../utils/room';
|
||||
import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp, getCanonicalAliasOrRoomId } from '../../utils/matrix';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp, getCanonicalAliasOrRoomId, encryptFile } from '../../utils/matrix';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { plainToEditorInput, toPlainText } from '../../components/editor';
|
||||
import { ShareRoomPicker } from '../../features/ShareRoomPicker';
|
||||
import {
|
||||
isTauri,
|
||||
@@ -46,10 +44,10 @@ import {
|
||||
} from '../../utils/backgroundSync';
|
||||
import {
|
||||
TUploadItem,
|
||||
roomIdToMsgDraftAtomFamily,
|
||||
roomIdToUploadItemsAtomFamily,
|
||||
} from '../../state/room/roomInputDrafts';
|
||||
import { encryptFile } from '../../utils/matrix';
|
||||
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
||||
import { safeFile } from '../../utils/mimeTypes';
|
||||
import {
|
||||
AndroidSharePayload,
|
||||
clearPendingAndroidShare,
|
||||
@@ -378,6 +376,14 @@ function MessageNotifications() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Match TitleBar / room bell settings: Default and Mentions rooms only notify on highlights.
|
||||
const notificationType = getNotificationType(mx, room.roomId);
|
||||
const isDm = mDirects.has(room.roomId);
|
||||
const mentionsOnly =
|
||||
notificationType === NotificationType.MentionsAndKeywords ||
|
||||
(notificationType === NotificationType.Default && !isDm);
|
||||
if (mentionsOnly && unreadInfo.highlight === 0) return;
|
||||
|
||||
if (
|
||||
showNotifications &&
|
||||
((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
|
||||
@@ -399,7 +405,6 @@ function MessageNotifications() {
|
||||
messageBody = typeof content.body === 'string' ? content.body : undefined;
|
||||
}
|
||||
|
||||
const isDm = room.getJoinedMemberCount() === 2 && !room.isSpaceRoom();
|
||||
notify({
|
||||
roomName: room.name ?? 'Unknown',
|
||||
roomAvatar: avatarMxc
|
||||
@@ -430,6 +435,7 @@ function MessageNotifications() {
|
||||
notify,
|
||||
selectedRoomId,
|
||||
useAuthentication,
|
||||
mDirects,
|
||||
]);
|
||||
|
||||
return null;
|
||||
@@ -517,10 +523,71 @@ function TaskbarFlashStopper() {
|
||||
|
||||
function AndroidShareIntentHandler() {
|
||||
const mx = useMatrixClient();
|
||||
const store = useStore();
|
||||
const navigate = useNavigate();
|
||||
const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null);
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const selectedRoomId = useSelectedRoom();
|
||||
const selectedRoomIdRef = useRef(selectedRoomId);
|
||||
selectedRoomIdRef.current = selectedRoomId;
|
||||
|
||||
/** Queue shared files into the open room's composer upload board (Gboard / paste / share). */
|
||||
const queueShareAsDrafts = useCallback(
|
||||
async (share: AndroidSharePayload, roomId: string) => {
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
if (share.files.length > 0) {
|
||||
const originalFiles: File[] = [];
|
||||
for (const sharedFile of share.files) {
|
||||
originalFiles.push(await materializeSharedFile(sharedFile, share.receivedAt));
|
||||
}
|
||||
const safeFiles = originalFiles.map(safeFile);
|
||||
const fileItems: TUploadItem[] = [];
|
||||
|
||||
if (room.hasEncryptionStateEvent()) {
|
||||
const encryptFiles = fulfilledPromiseSettledResult(
|
||||
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
|
||||
);
|
||||
encryptFiles.forEach((ef) =>
|
||||
fileItems.push({
|
||||
...ef,
|
||||
metadata: { markedAsSpoiler: false },
|
||||
})
|
||||
);
|
||||
} else {
|
||||
safeFiles.forEach((f) =>
|
||||
fileItems.push({
|
||||
file: f,
|
||||
originalFile: f,
|
||||
encInfo: undefined,
|
||||
metadata: { markedAsSpoiler: false },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
store.set(roomIdToUploadItemsAtomFamily(roomId), {
|
||||
type: 'PUT',
|
||||
item: fileItems,
|
||||
});
|
||||
}
|
||||
|
||||
const nextParts = [share.subject?.trim(), share.text?.trim()].filter(
|
||||
(value): value is string => !!value
|
||||
);
|
||||
if (nextParts.length > 0) {
|
||||
await mx.sendMessage(roomId, {
|
||||
msgtype: 'm.text' as const,
|
||||
body: nextParts.join('\n'),
|
||||
});
|
||||
}
|
||||
|
||||
await clearPendingAndroidShare();
|
||||
return true;
|
||||
},
|
||||
[mx, store]
|
||||
);
|
||||
|
||||
const applyPendingShare = useCallback(
|
||||
async (share: AndroidSharePayload, roomId: string) => {
|
||||
@@ -594,6 +661,22 @@ function AndroidShareIntentHandler() {
|
||||
[mx]
|
||||
);
|
||||
|
||||
const ingestShare = useCallback(
|
||||
(share: AndroidSharePayload) => {
|
||||
const roomId = selectedRoomIdRef.current;
|
||||
// Already in a room: attach to composer (Gboard GIF / paste / in-app share).
|
||||
if (roomId && share.files.length > 0) {
|
||||
queueShareAsDrafts(share, roomId).catch((err) => {
|
||||
console.error('[AndroidShare] Failed to queue share into room:', err);
|
||||
setPendingShare(share);
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPendingShare(share);
|
||||
},
|
||||
[queueShareAsDrafts]
|
||||
);
|
||||
|
||||
const handlePickRoom = useCallback(
|
||||
(roomId: string) => {
|
||||
if (!pendingShare) return;
|
||||
@@ -640,7 +723,7 @@ function AndroidShareIntentHandler() {
|
||||
getPendingAndroidShare()
|
||||
.then((share) => {
|
||||
if (mounted && share) {
|
||||
setPendingShare(share);
|
||||
ingestShare(share);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -649,7 +732,7 @@ function AndroidShareIntentHandler() {
|
||||
|
||||
listenForAndroidShares((share) => {
|
||||
if (mounted) {
|
||||
setPendingShare(share);
|
||||
ingestShare(share);
|
||||
}
|
||||
})
|
||||
.then((handle) => {
|
||||
@@ -663,7 +746,7 @@ function AndroidShareIntentHandler() {
|
||||
mounted = false;
|
||||
void listenerHandle?.remove();
|
||||
};
|
||||
}, []);
|
||||
}, [ingestShare]);
|
||||
|
||||
if (!pendingShare) return null;
|
||||
|
||||
|
||||
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);
|
||||
};
|
||||
@@ -7,6 +7,11 @@ export const isCapacitorAndroid = Boolean(
|
||||
cap?.isNativePlatform?.() && cap?.getPlatform?.() === 'android'
|
||||
);
|
||||
|
||||
// Stationery signatures use Caveat on all platforms (including Android WebView).
|
||||
await import('@fontsource/caveat/500.css');
|
||||
await import('@fontsource/caveat/600.css');
|
||||
await import('@fontsource/caveat/700.css');
|
||||
|
||||
if (isCapacitorAndroid) {
|
||||
document.documentElement.classList.add('android-capacitor');
|
||||
document.documentElement.style.setProperty(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
16
package-lock.json
generated
16
package-lock.json
generated
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"name": "paarrot",
|
||||
"version": "4.11.77",
|
||||
"version": "4.11.121",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paarrot",
|
||||
"version": "4.11.77",
|
||||
"version": "4.11.121",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.3.0",
|
||||
"@capacitor/cli": "8.3.0",
|
||||
"@capacitor/core": "8.3.0",
|
||||
"@capacitor/local-notifications": "8.0.2"
|
||||
"@capacitor/local-notifications": "8.0.2",
|
||||
"@capacitor/status-bar": "8.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"node-fetch": "3.3.2",
|
||||
@@ -82,6 +83,15 @@
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/status-bar": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.1.tgz",
|
||||
"integrity": "sha512-OR59dlbwvmrV5dKsC9lvwv48QaGbqcbSTBpk+9/WXWxXYSdXXdzJZU9p8oyNPAkuJhCdnSa3XmU43fZRPBJJ5w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paarrot",
|
||||
"version": "4.11.114",
|
||||
"version": "4.11.133",
|
||||
"description": "Paarrot - A Matrix client based on Cinny",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -25,7 +25,8 @@
|
||||
"@capacitor/android": "8.3.0",
|
||||
"@capacitor/cli": "8.3.0",
|
||||
"@capacitor/core": "8.3.0",
|
||||
"@capacitor/local-notifications": "8.0.2"
|
||||
"@capacitor/local-notifications": "8.0.2",
|
||||
"@capacitor/status-bar": "8.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"node-fetch": "3.3.2",
|
||||
|
||||
Reference in New Issue
Block a user