Compare commits
45 Commits
fbcb291b9a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f7af04a57 | ||
| 62b9c48fa1 | |||
|
|
19a46c4d95 | ||
| 8cf31b929d | |||
|
|
a4d56e095e | ||
| 86dc7f849a | |||
|
|
ca889f2eec | ||
| 157d707581 | |||
|
|
3940441d9b | ||
| cfabcc5fbf | |||
|
|
a40319e8c8 | ||
| 45bc475340 | |||
|
|
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 |
@@ -102,29 +102,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Android APK
|
- name: Build Android APK
|
||||||
run: npm run android:apk:linux
|
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
|
- name: Get version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
@@ -135,7 +113,14 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "Version: $VERSION"
|
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
|
- name: Check if tag exists
|
||||||
id: tag_check
|
id: tag_check
|
||||||
run: |
|
run: |
|
||||||
@@ -147,7 +132,7 @@ jobs:
|
|||||||
echo "Tag v$VERSION does not exist"
|
echo "Tag v$VERSION does not exist"
|
||||||
echo "exists=false" >> $GITHUB_OUTPUT
|
echo "exists=false" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Create and push tag
|
- name: Create and push tag
|
||||||
if: steps.tag_check.outputs.exists == 'false'
|
if: steps.tag_check.outputs.exists == 'false'
|
||||||
run: |
|
run: |
|
||||||
@@ -155,18 +140,9 @@ jobs:
|
|||||||
git config user.email "actions@github.com"
|
git config user.email "actions@github.com"
|
||||||
git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}"
|
git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}"
|
||||||
git push origin "v${{ steps.version.outputs.VERSION }}"
|
git push origin "v${{ steps.version.outputs.VERSION }}"
|
||||||
|
|
||||||
- name: Download all artifacts
|
# Upload via the internal Gitea API. actions/upload-artifact times out against
|
||||||
uses: actions/download-artifact@v3
|
# git.ruv.wtf:443 from this runner; synbox:8418 is reachable on the LAN.
|
||||||
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/
|
|
||||||
|
|
||||||
- name: Create or Update Gitea Release
|
- name: Create or Update Gitea Release
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -175,10 +151,10 @@ jobs:
|
|||||||
TAG_NAME="v${VERSION}"
|
TAG_NAME="v${VERSION}"
|
||||||
API_BASE="http://synbox.ruv.wtf:8418/api/v1"
|
API_BASE="http://synbox.ruv.wtf:8418/api/v1"
|
||||||
REPO="litruv/cinny-mobile"
|
REPO="litruv/cinny-mobile"
|
||||||
|
|
||||||
RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
"${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
|
"${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
|
||||||
|
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
echo "Creating new release for ${TAG_NAME}..."
|
echo "Creating new release for ${TAG_NAME}..."
|
||||||
RELEASE_ID=$(curl -s -X POST \
|
RELEASE_ID=$(curl -s -X POST \
|
||||||
@@ -198,7 +174,7 @@ jobs:
|
|||||||
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Uploading assets..."
|
echo "Uploading assets..."
|
||||||
for FILE in release-files/*; do
|
for FILE in release-files/*; do
|
||||||
FILENAME="Paarrot-${VERSION}.apk"
|
FILENAME="Paarrot-${VERSION}.apk"
|
||||||
@@ -210,9 +186,9 @@ jobs:
|
|||||||
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
|
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
|
||||||
echo ""
|
echo ""
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "Gitea release complete!"
|
echo "Gitea release complete!"
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GHTOKEN }}
|
GH_TOKEN: ${{ secrets.GHTOKEN }}
|
||||||
@@ -220,10 +196,10 @@ jobs:
|
|||||||
VERSION="${{ steps.version.outputs.VERSION }}"
|
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||||
TAG_NAME="v${VERSION}"
|
TAG_NAME="v${VERSION}"
|
||||||
GITHUB_REPO="Paarrot/Paarrot-Mobile"
|
GITHUB_REPO="Paarrot/Paarrot-Mobile"
|
||||||
|
|
||||||
RELEASE_ID=$(curl -s -H "Authorization: token ${GH_TOKEN}" \
|
RELEASE_ID=$(curl -s -H "Authorization: token ${GH_TOKEN}" \
|
||||||
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
|
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
|
||||||
|
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
echo "Creating new GitHub release for ${TAG_NAME}..."
|
echo "Creating new GitHub release for ${TAG_NAME}..."
|
||||||
RELEASE_ID=$(curl -s -X POST \
|
RELEASE_ID=$(curl -s -X POST \
|
||||||
@@ -235,11 +211,11 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "GitHub release exists with ID: ${RELEASE_ID}"
|
echo "GitHub release exists with ID: ${RELEASE_ID}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Uploading assets to GitHub..."
|
echo "Uploading assets to GitHub..."
|
||||||
UPLOAD_URL=$(curl -s -H "Authorization: token ${GH_TOKEN}" \
|
UPLOAD_URL=$(curl -s -H "Authorization: token ${GH_TOKEN}" \
|
||||||
"https://api.github.com/repos/${GITHUB_REPO}/releases/${RELEASE_ID}" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
"https://api.github.com/repos/${GITHUB_REPO}/releases/${RELEASE_ID}" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
||||||
|
|
||||||
for FILE in release-files/*; do
|
for FILE in release-files/*; do
|
||||||
FILENAME="Paarrot-${VERSION}.apk"
|
FILENAME="Paarrot-${VERSION}.apk"
|
||||||
echo "Uploading ${FILENAME} to GitHub..."
|
echo "Uploading ${FILENAME} to GitHub..."
|
||||||
@@ -250,5 +226,5 @@ jobs:
|
|||||||
"${UPLOAD_URL}?name=${FILENAME}"
|
"${UPLOAD_URL}?name=${FILENAME}"
|
||||||
echo ""
|
echo ""
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "GitHub release complete!"
|
echo "GitHub release complete!"
|
||||||
|
|||||||
2
.gitmodules
vendored
2
.gitmodules
vendored
@@ -1,3 +1,3 @@
|
|||||||
[submodule "cinny"]
|
[submodule "cinny"]
|
||||||
path = cinny
|
path = cinny
|
||||||
url = http://synbox.ruv.wtf:8418/litruv/cinny.git
|
url = ../cinny.git
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.paarrot.app
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.view.View
|
||||||
|
import androidx.core.view.ContentInfoCompat
|
||||||
|
import androidx.core.view.OnReceiveContentListener
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receives rich content from the IME (Gboard GIF), clipboard paste, and drag-drop,
|
||||||
|
* then forwards file URIs through [ShareIntentStore] so JS can attach them in-room.
|
||||||
|
*/
|
||||||
|
class ImageKeyboardContentReceiver : OnReceiveContentListener {
|
||||||
|
|
||||||
|
override fun onReceiveContent(view: View, payload: ContentInfoCompat): ContentInfoCompat? {
|
||||||
|
val split = payload.partition { item -> item.uri != null }
|
||||||
|
val uriContent = split.first
|
||||||
|
val remaining = split.second
|
||||||
|
|
||||||
|
if (uriContent != null) {
|
||||||
|
val uris = mutableListOf<Uri>()
|
||||||
|
val clip = uriContent.clip
|
||||||
|
for (i in 0 until clip.itemCount) {
|
||||||
|
clip.getItemAt(i).uri?.let { uris.add(it) }
|
||||||
|
}
|
||||||
|
if (uris.isNotEmpty()) {
|
||||||
|
ShareIntentStore.handleContentUris(view.context, uris)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return remaining
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.paarrot.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import android.view.inputmethod.InputConnection
|
||||||
|
import androidx.core.view.inputmethod.EditorInfoCompat
|
||||||
|
import androidx.core.view.inputmethod.InputConnectionCompat
|
||||||
|
import com.getcapacitor.CapacitorWebView
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capacitor WebView that advertises image/video MIME types to the IME
|
||||||
|
* (Gboard GIF/sticker keyboard) and routes commit-content through
|
||||||
|
* [androidx.core.view.ViewCompat.setOnReceiveContentListener].
|
||||||
|
*/
|
||||||
|
class ImageKeyboardWebView(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet?,
|
||||||
|
) : CapacitorWebView(context, attrs) {
|
||||||
|
|
||||||
|
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
|
||||||
|
val inputConnection = super.onCreateInputConnection(outAttrs) ?: return null
|
||||||
|
EditorInfoCompat.setContentMimeTypes(outAttrs, MIME_TYPES)
|
||||||
|
return InputConnectionCompat.createWrapper(this, inputConnection, outAttrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@JvmField
|
||||||
|
val MIME_TYPES: Array<String> = arrayOf(
|
||||||
|
"image/*",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/webp",
|
||||||
|
"video/*",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.paarrot.app;
|
package com.paarrot.app;
|
||||||
|
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.webkit.WebView;
|
||||||
|
import androidx.core.view.ViewCompat;
|
||||||
import com.getcapacitor.BridgeActivity;
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
public class MainActivity extends BridgeActivity {
|
public class MainActivity extends BridgeActivity {
|
||||||
@@ -10,5 +12,31 @@ public class MainActivity extends BridgeActivity {
|
|||||||
registerPlugin(ShareHandlerPlugin.class);
|
registerPlugin(ShareHandlerPlugin.class);
|
||||||
registerPlugin(PluginStoragePlugin.class);
|
registerPlugin(PluginStoragePlugin.class);
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
setupImageKeyboardSupport();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@Override
|
||||||
|
public void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
// Keep native sync suppression in sync with the real Activity lifecycle.
|
||||||
|
MatrixSyncService.appInForeground = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPause() {
|
||||||
|
MatrixSyncService.appInForeground = false;
|
||||||
|
super.onPause();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
|||||||
|
package com.paarrot.app
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridges notification tap intents into the Capacitor JS layer so we can
|
||||||
|
* navigate to the correct room when a tray notification is opened.
|
||||||
|
*/
|
||||||
|
object NotificationNavStore {
|
||||||
|
const val EXTRA_NAV_PATH = "paarrot_nav_path"
|
||||||
|
const val ACTION_OPEN_NOTIFICATION = "com.paarrot.app.OPEN_NOTIFICATION"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var pendingPath: String? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var pendingRoomId: String? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var plugin: SyncServicePlugin? = null
|
||||||
|
|
||||||
|
/** Read navigation extras from a notification tap [intent] and notify JS. */
|
||||||
|
fun handleIntent(intent: Intent?) {
|
||||||
|
if (intent == null) return
|
||||||
|
val path = intent.getStringExtra(EXTRA_NAV_PATH)?.takeIf { it.isNotBlank() }
|
||||||
|
val roomId = intent.getStringExtra(MatrixSyncService.EXTRA_ROOM_ID)?.takeIf { it.isNotBlank() }
|
||||||
|
if (path == null && roomId == null) return
|
||||||
|
|
||||||
|
pendingPath = path
|
||||||
|
pendingRoomId = roomId
|
||||||
|
plugin?.emitNotificationOpened(path, roomId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consume and clear the pending navigation target. */
|
||||||
|
fun consume(): Pair<String?, String?> {
|
||||||
|
val path = pendingPath
|
||||||
|
val roomId = pendingRoomId
|
||||||
|
pendingPath = null
|
||||||
|
pendingRoomId = null
|
||||||
|
return path to roomId
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
package com.paarrot.app
|
package com.paarrot.app
|
||||||
|
|
||||||
import android.app.Activity
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.util.Base64
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
import com.getcapacitor.JSArray
|
import com.getcapacitor.JSArray
|
||||||
import com.getcapacitor.JSObject
|
import com.getcapacitor.JSObject
|
||||||
import com.getcapacitor.Plugin
|
import com.getcapacitor.Plugin
|
||||||
@@ -62,7 +66,6 @@ object ShareIntentStore {
|
|||||||
|
|
||||||
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return
|
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return
|
||||||
|
|
||||||
val receivedAt = System.currentTimeMillis()
|
|
||||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||||
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
|
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
|
||||||
|
|
||||||
@@ -86,8 +89,29 @@ object ShareIntentStore {
|
|||||||
uri?.let { uris.add(it) }
|
uri?.let { uris.add(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deliverShare(context, uris, text, subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver content URIs from the IME (Gboard GIF), clipboard paste, or drag-drop
|
||||||
|
* into the same share pipeline used by ACTION_SEND.
|
||||||
|
*/
|
||||||
|
fun handleContentUris(context: Context, uris: List<Uri>) {
|
||||||
|
if (uris.isEmpty()) return
|
||||||
|
deliverShare(context, uris, text = null, subject = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deliverShare(
|
||||||
|
context: Context,
|
||||||
|
uris: List<Uri>,
|
||||||
|
text: String?,
|
||||||
|
subject: String?,
|
||||||
|
) {
|
||||||
|
val receivedAt = System.currentTimeMillis()
|
||||||
val copiedFiles = uris.mapNotNull { copySharedUri(context, it, receivedAt) }
|
val copiedFiles = uris.mapNotNull { copySharedUri(context, it, receivedAt) }
|
||||||
|
|
||||||
|
if (copiedFiles.isEmpty() && text.isNullOrBlank() && subject.isNullOrBlank()) return
|
||||||
|
|
||||||
val payload = SharePayload(
|
val payload = SharePayload(
|
||||||
text = text,
|
text = text,
|
||||||
subject = subject,
|
subject = subject,
|
||||||
@@ -193,4 +217,168 @@ class ShareHandlerPlugin : Plugin() {
|
|||||||
ShareIntentStore.clearIntent(ShareIntentStore.pendingShare?.receivedAt)
|
ShareIntentStore.clearIntent(ShareIntentStore.pendingShare?.receivedAt)
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a base64-encoded file to the device gallery / Downloads.
|
||||||
|
* Capacitor WebView cannot use FileSaver / `<a download>`, so downloads go through MediaStore.
|
||||||
|
* Images → Pictures/Paarrot, videos → Movies/Paarrot, other → Download/Paarrot (API 29+).
|
||||||
|
* On older APIs or MediaStore failure, falls back to the system share sheet.
|
||||||
|
*/
|
||||||
|
@PluginMethod
|
||||||
|
fun saveFile(call: PluginCall) {
|
||||||
|
val filename = call.getString("filename") ?: run {
|
||||||
|
call.reject("filename required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val mimeType = call.getString("mimeType")
|
||||||
|
?: guessMime(filename)
|
||||||
|
?: "application/octet-stream"
|
||||||
|
val base64 = call.getString("base64") ?: run {
|
||||||
|
call.reject("base64 required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val cleaned = base64.substringAfter("base64,", base64).trim()
|
||||||
|
val bytes = Base64.decode(cleaned, Base64.DEFAULT)
|
||||||
|
if (bytes.isEmpty()) {
|
||||||
|
call.reject("empty file data")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val uri = saveBytes(bytes, filename, mimeType)
|
||||||
|
if (uri != null) {
|
||||||
|
android.widget.Toast.makeText(
|
||||||
|
context,
|
||||||
|
"Saved $filename",
|
||||||
|
android.widget.Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
call.resolve(
|
||||||
|
JSObject()
|
||||||
|
.put("saved", true)
|
||||||
|
.put("uri", uri.toString())
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
shareBytes(bytes, filename, mimeType)
|
||||||
|
call.resolve(JSObject().put("saved", false).put("shared", true))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e(TAG, "saveFile failed", e)
|
||||||
|
call.reject("Failed to save file: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the system share sheet for a base64 file (Save to Files, Drive, etc.).
|
||||||
|
*/
|
||||||
|
@PluginMethod
|
||||||
|
fun shareFile(call: PluginCall) {
|
||||||
|
val filename = call.getString("filename") ?: run {
|
||||||
|
call.reject("filename required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val mimeType = call.getString("mimeType")
|
||||||
|
?: guessMime(filename)
|
||||||
|
?: "application/octet-stream"
|
||||||
|
val base64 = call.getString("base64") ?: run {
|
||||||
|
call.reject("base64 required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val cleaned = base64.substringAfter("base64,", base64).trim()
|
||||||
|
val bytes = Base64.decode(cleaned, Base64.DEFAULT)
|
||||||
|
shareBytes(bytes, filename, mimeType)
|
||||||
|
call.resolve(JSObject().put("shared", true))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e(TAG, "shareFile failed", e)
|
||||||
|
call.reject("Failed to share file: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveBytes(bytes: ByteArray, filename: String, mimeType: String): Uri? {
|
||||||
|
return when {
|
||||||
|
mimeType.startsWith("image/") ->
|
||||||
|
insertMedia(bytes, filename, mimeType, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "Pictures/Paarrot")
|
||||||
|
mimeType.startsWith("video/") ->
|
||||||
|
insertMedia(bytes, filename, mimeType, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, "Movies/Paarrot")
|
||||||
|
mimeType.startsWith("audio/") ->
|
||||||
|
insertMedia(bytes, filename, mimeType, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, "Music/Paarrot")
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ->
|
||||||
|
insertMedia(bytes, filename, mimeType, MediaStore.Downloads.EXTERNAL_CONTENT_URI, "Download/Paarrot")
|
||||||
|
else -> null // share-sheet fallback (avoids WRITE_EXTERNAL_STORAGE on API < 29)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun insertMedia(
|
||||||
|
bytes: ByteArray,
|
||||||
|
filename: String,
|
||||||
|
mimeType: String,
|
||||||
|
collection: Uri,
|
||||||
|
relativePath: String
|
||||||
|
): Uri? {
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
|
||||||
|
put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
|
||||||
|
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val uri = resolver.insert(collection, values) ?: return null
|
||||||
|
try {
|
||||||
|
resolver.openOutputStream(uri)?.use { it.write(bytes) }
|
||||||
|
?: run {
|
||||||
|
resolver.delete(uri, null, null)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
resolver.delete(uri, null, null)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
values.clear()
|
||||||
|
values.put(MediaStore.MediaColumns.IS_PENDING, 0)
|
||||||
|
resolver.update(uri, values, null, null)
|
||||||
|
}
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shareBytes(bytes: ByteArray, filename: String, mimeType: String) {
|
||||||
|
val cacheDir = File(context.cacheDir, "shares").apply { mkdirs() }
|
||||||
|
val safeName = filename.replace(Regex("[^a-zA-Z0-9._\\-]"), "_").take(128).ifEmpty { "file" }
|
||||||
|
val file = File(cacheDir, safeName)
|
||||||
|
FileOutputStream(file).use { it.write(bytes) }
|
||||||
|
|
||||||
|
val uri = FileProvider.getUriForFile(
|
||||||
|
context,
|
||||||
|
"${context.packageName}.fileprovider",
|
||||||
|
file
|
||||||
|
)
|
||||||
|
|
||||||
|
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = mimeType
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
putExtra(Intent.EXTRA_SUBJECT, filename)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
|
||||||
|
val chooser = Intent.createChooser(intent, "Save or share").apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
context.startActivity(chooser)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun guessMime(filename: String): String? {
|
||||||
|
val ext = filename.substringAfterLast('.', "").lowercase()
|
||||||
|
if (ext.isEmpty()) return null
|
||||||
|
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ShareHandlerPlugin"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import com.getcapacitor.annotation.CapacitorPlugin
|
|||||||
* - `stop()` — clear persisted credentials and unregister UnifiedPush
|
* - `stop()` — clear persisted credentials and unregister UnifiedPush
|
||||||
* - `setAppForeground({ foreground })` — tell the service whether the app UI is visible
|
* - `setAppForeground({ foreground })` — tell the service whether the app UI is visible
|
||||||
* - `getStatus()` — returns current fetch and UnifiedPush state
|
* - `getStatus()` — returns current fetch and UnifiedPush state
|
||||||
|
* - `clearRoomNotifications({ roomId })` — dismiss native tray notifs for a room
|
||||||
|
* - `setNotificationGroups({ rooms })` — persist space/DM grouping for tray nesting
|
||||||
|
* - `showNotification({ title, body, roomId, groupId, groupName, kind, largeIconBase64 })`
|
||||||
*/
|
*/
|
||||||
@CapacitorPlugin(name = "MatrixBackgroundSync")
|
@CapacitorPlugin(name = "MatrixBackgroundSync")
|
||||||
class SyncServicePlugin : Plugin() {
|
class SyncServicePlugin : Plugin() {
|
||||||
@@ -28,13 +31,44 @@ class SyncServicePlugin : Plugin() {
|
|||||||
override fun load() {
|
override fun load() {
|
||||||
super.load()
|
super.load()
|
||||||
UnifiedPushManager.setPlugin(this)
|
UnifiedPushManager.setPlugin(this)
|
||||||
|
NotificationNavStore.plugin = this
|
||||||
|
NotificationNavStore.handleIntent(activity?.intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleOnNewIntent(intent: Intent?) {
|
||||||
|
super.handleOnNewIntent(intent)
|
||||||
|
if (intent != null) {
|
||||||
|
activity?.intent = intent
|
||||||
|
}
|
||||||
|
NotificationNavStore.handleIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleOnDestroy() {
|
override fun handleOnDestroy() {
|
||||||
UnifiedPushManager.clearPlugin(this)
|
UnifiedPushManager.clearPlugin(this)
|
||||||
|
if (NotificationNavStore.plugin === this) {
|
||||||
|
NotificationNavStore.plugin = null
|
||||||
|
}
|
||||||
super.handleOnDestroy()
|
super.handleOnDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Emit a notificationOpened event toward the JS layer. */
|
||||||
|
fun emitNotificationOpened(path: String?, roomId: String?) {
|
||||||
|
val payload = JSObject()
|
||||||
|
if (path != null) payload.put("path", path)
|
||||||
|
if (roomId != null) payload.put("roomId", roomId)
|
||||||
|
notifyListeners("notificationOpened", payload, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns any pending notification navigation target from a tray tap. */
|
||||||
|
@PluginMethod
|
||||||
|
fun getPendingNotificationNav(call: PluginCall) {
|
||||||
|
val (path, roomId) = NotificationNavStore.consume()
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("path", path)
|
||||||
|
result.put("roomId", roomId)
|
||||||
|
call.resolve(result)
|
||||||
|
}
|
||||||
|
|
||||||
/** Persists Matrix credentials and starts UnifiedPush registration. */
|
/** Persists Matrix credentials and starts UnifiedPush registration. */
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun start(call: PluginCall) {
|
fun start(call: PluginCall) {
|
||||||
@@ -97,6 +131,69 @@ class SyncServicePlugin : Plugin() {
|
|||||||
call.resolve(result)
|
call.resolve(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels native tray notifications for a Matrix room after it has been marked as read.
|
||||||
|
*/
|
||||||
|
@PluginMethod
|
||||||
|
fun clearRoomNotifications(call: PluginCall) {
|
||||||
|
val roomId = call.getString("roomId")
|
||||||
|
?: return call.reject("roomId required")
|
||||||
|
MatrixSyncService.clearRoomNotifications(context, roomId)
|
||||||
|
call.resolve(JSObject().put("cleared", true))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists room → space/group metadata from the JS layer so background
|
||||||
|
* notifications can nest under Direct messages / Space name / Home.
|
||||||
|
*/
|
||||||
|
@PluginMethod
|
||||||
|
fun setNotificationGroups(call: PluginCall) {
|
||||||
|
val rooms = call.getObject("rooms")
|
||||||
|
?: return call.reject("rooms required")
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||||
|
.putString(MatrixSyncService.KEY_NOTIFICATION_GROUPS, rooms.toString())
|
||||||
|
.apply()
|
||||||
|
call.resolve(JSObject().put("success", true))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a message notification from the JS layer (supports dynamic avatar icons).
|
||||||
|
* Capacitor LocalNotifications cannot load authenticated/dynamic large icons, so
|
||||||
|
* we post through the same native NotificationManager path as background sync.
|
||||||
|
*/
|
||||||
|
@PluginMethod
|
||||||
|
fun showNotification(call: PluginCall) {
|
||||||
|
val roomId = call.getString("roomId") ?: return call.reject("roomId required")
|
||||||
|
val groupId = call.getString("groupId") ?: "paarrot_home"
|
||||||
|
val groupName = call.getString("groupName") ?: "Home"
|
||||||
|
val kind = call.getString("kind") ?: "home"
|
||||||
|
val senderName = call.getString("senderName")
|
||||||
|
?: call.getString("title")
|
||||||
|
?: "Someone"
|
||||||
|
val messageText = call.getString("messageText")
|
||||||
|
?: call.getString("body")
|
||||||
|
?: "New message"
|
||||||
|
val conversationTitle = call.getString("conversationTitle")
|
||||||
|
val path = call.getString("path")
|
||||||
|
val largeIconBase64 = call.getString("largeIconBase64")
|
||||||
|
val bigPictureBase64 = call.getString("bigPictureBase64")
|
||||||
|
|
||||||
|
MatrixSyncService.postMessageNotification(
|
||||||
|
context = context,
|
||||||
|
roomId = roomId,
|
||||||
|
senderName = senderName,
|
||||||
|
messageText = messageText,
|
||||||
|
conversationTitle = conversationTitle,
|
||||||
|
groupId = groupId,
|
||||||
|
groupName = groupName,
|
||||||
|
kind = kind,
|
||||||
|
largeIcon = MatrixSyncService.decodeBase64Bitmap(largeIconBase64),
|
||||||
|
inlineImage = MatrixSyncService.decodeBase64Bitmap(bigPictureBase64),
|
||||||
|
path = path,
|
||||||
|
)
|
||||||
|
call.resolve(JSObject().put("shown", true))
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val PREFS = "sync_service_prefs"
|
const val PREFS = "sync_service_prefs"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
251
overlay/src/app/components/Pdf-viewer/PdfViewer.tsx
Normal file
251
overlay/src/app/components/Pdf-viewer/PdfViewer.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
/* eslint-disable no-param-reassign */
|
||||||
|
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
||||||
|
import React, { FormEventHandler, MouseEventHandler, useEffect, useRef, useState } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { Box, Button, Chip, Header, IconButton, Input, Menu, PopOut, RectCords, Scroll, Spinner, Text, as, config } from 'folds';
|
||||||
|
import { Icon, Icons } from '../icons';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { saveMediaBlob } from '../../utils/saveMedia';
|
||||||
|
import * as css from './PdfViewer.css';
|
||||||
|
import { AsyncStatus } from '../../hooks/useAsyncCallback';
|
||||||
|
import { useZoom } from '../../hooks/useZoom';
|
||||||
|
import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
export type PdfViewerProps = {
|
||||||
|
name: string;
|
||||||
|
src: string;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PdfViewer = as<'div', PdfViewerProps>(
|
||||||
|
({ className, name, src, requestClose, ...props }, ref) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||||
|
|
||||||
|
const [pdfJSState, loadPdfJS] = usePdfJSLoader();
|
||||||
|
const [docState, loadPdfDocument] = usePdfDocumentLoader(
|
||||||
|
pdfJSState.status === AsyncStatus.Success ? pdfJSState.data : undefined,
|
||||||
|
src
|
||||||
|
);
|
||||||
|
const isLoading =
|
||||||
|
pdfJSState.status === AsyncStatus.Loading || docState.status === AsyncStatus.Loading;
|
||||||
|
const isError =
|
||||||
|
pdfJSState.status === AsyncStatus.Error || docState.status === AsyncStatus.Error;
|
||||||
|
const [pageNo, setPageNo] = useState(1);
|
||||||
|
const [jumpAnchor, setJumpAnchor] = useState<RectCords>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPdfJS();
|
||||||
|
}, [loadPdfJS]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (pdfJSState.status === AsyncStatus.Success) {
|
||||||
|
loadPdfDocument();
|
||||||
|
}
|
||||||
|
}, [pdfJSState, loadPdfDocument]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (docState.status === AsyncStatus.Success) {
|
||||||
|
const doc = docState.data;
|
||||||
|
if (pageNo < 0 || pageNo > doc.numPages) return;
|
||||||
|
createPage(doc, pageNo, { scale: zoom }).then((canvas) => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
container.textContent = '';
|
||||||
|
container.append(canvas);
|
||||||
|
scrollRef.current?.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [docState, pageNo, zoom]);
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(src);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch PDF');
|
||||||
|
await saveMediaBlob(await res.blob(), name);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[PdfViewer] Failed to download:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
if (docState.status !== AsyncStatus.Success) return;
|
||||||
|
const jumpInput = evt.currentTarget.jumpInput as HTMLInputElement;
|
||||||
|
if (!jumpInput) return;
|
||||||
|
const jumpTo = parseInt(jumpInput.value, 10);
|
||||||
|
setPageNo(Math.max(1, Math.min(docState.data.numPages, jumpTo)));
|
||||||
|
setJumpAnchor(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevPage = () => {
|
||||||
|
setPageNo((n) => Math.max(n - 1, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextPage = () => {
|
||||||
|
if (docState.status !== AsyncStatus.Success) return;
|
||||||
|
setPageNo((n) => Math.min(n + 1, docState.data.numPages));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenJump: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setJumpAnchor(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}>
|
||||||
|
<Header className={css.PdfViewerHeader} size="400">
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<IconButton size="300" radii="300" onClick={requestClose}>
|
||||||
|
<Icon size="50" src={Icons.ArrowLeft} />
|
||||||
|
</IconButton>
|
||||||
|
<Text size="T300" truncate>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No" alignItems="Center" gap="200">
|
||||||
|
<IconButton
|
||||||
|
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
|
||||||
|
outlined={zoom < 1}
|
||||||
|
size="300"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={zoomOut}
|
||||||
|
aria-label="Zoom Out"
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.Minus} />
|
||||||
|
</IconButton>
|
||||||
|
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
|
||||||
|
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
||||||
|
</Chip>
|
||||||
|
<IconButton
|
||||||
|
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
|
||||||
|
outlined={zoom > 1}
|
||||||
|
size="300"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={zoomIn}
|
||||||
|
aria-label="Zoom In"
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.Plus} />
|
||||||
|
</IconButton>
|
||||||
|
<Chip
|
||||||
|
variant="Primary"
|
||||||
|
onClick={handleDownload}
|
||||||
|
radii="300"
|
||||||
|
before={<Icon size="50" src={Icons.Download} />}
|
||||||
|
>
|
||||||
|
<Text size="B300">Download</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
</Header>
|
||||||
|
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
|
||||||
|
{isLoading && <Spinner variant="Secondary" size="600" />}
|
||||||
|
{isError && (
|
||||||
|
<>
|
||||||
|
<Text>Failed to load PDF</Text>
|
||||||
|
<Button
|
||||||
|
variant="Critical"
|
||||||
|
fill="Soft"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
before={<Icon src={Icons.Warning} size="50" />}
|
||||||
|
onClick={loadPdfJS}
|
||||||
|
>
|
||||||
|
<Text size="B300">Retry</Text>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{docState.status === AsyncStatus.Success && (
|
||||||
|
<Scroll
|
||||||
|
ref={scrollRef}
|
||||||
|
size="300"
|
||||||
|
direction="Both"
|
||||||
|
variant="Surface"
|
||||||
|
visibility="Hover"
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<div className={css.PdfViewerContent} ref={containerRef} />
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{docState.status === AsyncStatus.Success && (
|
||||||
|
<Header as="footer" className={css.PdfViewerFooter} size="400">
|
||||||
|
<Chip
|
||||||
|
variant="Secondary"
|
||||||
|
radii="300"
|
||||||
|
before={<Icon size="50" src={Icons.ChevronLeft} />}
|
||||||
|
onClick={handlePrevPage}
|
||||||
|
aria-disabled={pageNo <= 1}
|
||||||
|
>
|
||||||
|
<Text size="B300">Previous</Text>
|
||||||
|
</Chip>
|
||||||
|
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
|
||||||
|
<PopOut
|
||||||
|
anchor={jumpAnchor}
|
||||||
|
align="Center"
|
||||||
|
position="Top"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setJumpAnchor(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu variant="Surface">
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
onSubmit={handleJumpSubmit}
|
||||||
|
style={{ padding: config.space.S200 }}
|
||||||
|
direction="Column"
|
||||||
|
gap="200"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
name="jumpInput"
|
||||||
|
size="300"
|
||||||
|
variant="Background"
|
||||||
|
defaultValue={pageNo}
|
||||||
|
min={1}
|
||||||
|
max={docState.data.numPages}
|
||||||
|
step={1}
|
||||||
|
outlined
|
||||||
|
type="number"
|
||||||
|
radii="300"
|
||||||
|
aria-label="Page Number"
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="300" variant="Primary" radii="300">
|
||||||
|
<Text size="B300">Jump To Page</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
onClick={handleOpenJump}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={jumpAnchor !== undefined}
|
||||||
|
>
|
||||||
|
<Text size="B300">{`${pageNo}/${docState.data.numPages}`}</Text>
|
||||||
|
</Chip>
|
||||||
|
</PopOut>
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
variant="Primary"
|
||||||
|
radii="300"
|
||||||
|
after={<Icon size="50" src={Icons.ChevronRight} />}
|
||||||
|
onClick={handleNextPage}
|
||||||
|
aria-disabled={pageNo >= docState.data.numPages}
|
||||||
|
>
|
||||||
|
<Text size="B300">Next</Text>
|
||||||
|
</Chip>
|
||||||
|
</Header>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
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,
|
||||||
|
});
|
||||||
107
overlay/src/app/components/image-viewer/ImageViewer.tsx
Normal file
107
overlay/src/app/components/image-viewer/ImageViewer.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
||||||
|
import React from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { Box, Chip, Header, IconButton, Text, as } from 'folds';
|
||||||
|
import { Icon, Icons } from '../icons';
|
||||||
|
import * as css from './ImageViewer.css';
|
||||||
|
import { useZoom } from '../../hooks/useZoom';
|
||||||
|
import { usePan } from '../../hooks/usePan';
|
||||||
|
import { downloadAndSaveMedia } from '../../utils/saveMedia';
|
||||||
|
import { getCurrentAccessToken } from '../../utils/auth';
|
||||||
|
|
||||||
|
export type ImageViewerProps = {
|
||||||
|
alt: string;
|
||||||
|
src: string;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ImageViewer = as<'div', ImageViewerProps>(
|
||||||
|
({ className, alt, src, requestClose, ...props }, ref) => {
|
||||||
|
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||||
|
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
try {
|
||||||
|
await downloadAndSaveMedia(src, alt, getCurrentAccessToken());
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ImageViewer] Failed to download media:', error);
|
||||||
|
try {
|
||||||
|
window.open(src, '_blank');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className={classNames(css.ImageViewer, className)}
|
||||||
|
direction="Column"
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Header className={css.ImageViewerHeader} size="400">
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<IconButton size="300" radii="300" onClick={requestClose}>
|
||||||
|
<Icon size="50" src={Icons.ArrowLeft} />
|
||||||
|
</IconButton>
|
||||||
|
<Text size="T300" truncate>
|
||||||
|
{alt}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No" alignItems="Center" gap="200">
|
||||||
|
<IconButton
|
||||||
|
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
|
||||||
|
outlined={zoom < 1}
|
||||||
|
size="300"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={zoomOut}
|
||||||
|
aria-label="Zoom Out"
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.Minus} />
|
||||||
|
</IconButton>
|
||||||
|
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
|
||||||
|
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
||||||
|
</Chip>
|
||||||
|
<IconButton
|
||||||
|
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
|
||||||
|
outlined={zoom > 1}
|
||||||
|
size="300"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={zoomIn}
|
||||||
|
aria-label="Zoom In"
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.Plus} />
|
||||||
|
</IconButton>
|
||||||
|
<Chip
|
||||||
|
variant="Primary"
|
||||||
|
onClick={handleDownload}
|
||||||
|
radii="300"
|
||||||
|
before={<Icon size="50" src={Icons.Download} />}
|
||||||
|
>
|
||||||
|
<Text size="B300">Download</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
</Header>
|
||||||
|
<Box
|
||||||
|
grow="Yes"
|
||||||
|
className={css.ImageViewerContent}
|
||||||
|
justifyContent="Center"
|
||||||
|
alignItems="Center"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className={css.ImageViewerImg}
|
||||||
|
style={{
|
||||||
|
cursor,
|
||||||
|
transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`,
|
||||||
|
}}
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
draggable={false}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
84
overlay/src/app/components/message/FileHeader.tsx
Normal file
84
overlay/src/app/components/message/FileHeader.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { Badge, Box, IconButton, Spinner, Text, as, toRem } from 'folds';
|
||||||
|
import { Icon, Icons } from '../icons';
|
||||||
|
import React, { ReactNode, useCallback } from 'react';
|
||||||
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
|
import { mimeTypeToExt } from '../../utils/mimeTypes';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
import {
|
||||||
|
decryptFile,
|
||||||
|
downloadEncryptedMedia,
|
||||||
|
downloadMedia,
|
||||||
|
mxcUrlToHttp,
|
||||||
|
} from '../../utils/matrix';
|
||||||
|
import { getCurrentAccessToken } from '../../utils/auth';
|
||||||
|
import { saveMediaBlob } from '../../utils/saveMedia';
|
||||||
|
|
||||||
|
const badgeStyles = { maxWidth: toRem(100) };
|
||||||
|
|
||||||
|
type FileDownloadButtonProps = {
|
||||||
|
filename: string;
|
||||||
|
url: string;
|
||||||
|
mimeType: string;
|
||||||
|
encInfo?: EncryptedAttachmentInfo;
|
||||||
|
};
|
||||||
|
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
||||||
|
const [downloadState, download] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||||
|
// Always use current session's token to avoid stale tokens during account switches
|
||||||
|
const accessToken = getCurrentAccessToken();
|
||||||
|
const fileContent = encInfo
|
||||||
|
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
||||||
|
: await downloadMedia(mediaUrl, accessToken);
|
||||||
|
|
||||||
|
await saveMediaBlob(fileContent, filename);
|
||||||
|
return true;
|
||||||
|
}, [mx, url, useAuthentication, mimeType, encInfo, filename])
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloading = downloadState.status === AsyncStatus.Loading;
|
||||||
|
const hasError = downloadState.status === AsyncStatus.Error;
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
disabled={downloading}
|
||||||
|
onClick={download}
|
||||||
|
variant={hasError ? 'Critical' : 'SurfaceVariant'}
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
{downloading ? (
|
||||||
|
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
|
||||||
|
) : (
|
||||||
|
<Icon size="100" src={Icons.Download} />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileHeaderProps = {
|
||||||
|
body: string;
|
||||||
|
mimeType: string;
|
||||||
|
after?: ReactNode;
|
||||||
|
};
|
||||||
|
export const FileHeader = as<'div', FileHeaderProps>(({ body, mimeType, after, ...props }, ref) => (
|
||||||
|
<Box alignItems="Center" gap="200" grow="Yes" {...props} ref={ref}>
|
||||||
|
<Box shrink="No">
|
||||||
|
<Badge style={badgeStyles} variant="Secondary" radii="Pill">
|
||||||
|
<Text size="O400" truncate>
|
||||||
|
{mimeTypeToExt(mimeType)}
|
||||||
|
</Text>
|
||||||
|
</Badge>
|
||||||
|
</Box>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="T300" truncate>
|
||||||
|
{body}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{after}
|
||||||
|
</Box>
|
||||||
|
));
|
||||||
303
overlay/src/app/components/message/content/FileContent.tsx
Normal file
303
overlay/src/app/components/message/content/FileContent.tsx
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import React, { ReactNode, useCallback, useState } from 'react';
|
||||||
|
import { Box, Button, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
|
||||||
|
import { Icon, Icons } from '../../icons';
|
||||||
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
|
import { saveMediaBlob } from '../../../utils/saveMedia';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { IFileInfo } from '../../../../types/matrix/common';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { bytesToSize } from '../../../utils/common';
|
||||||
|
import {
|
||||||
|
READABLE_EXT_TO_MIME_TYPE,
|
||||||
|
READABLE_TEXT_MIME_TYPES,
|
||||||
|
getFileNameExt,
|
||||||
|
mimeTypeToExt,
|
||||||
|
} from '../../../utils/mimeTypes';
|
||||||
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
|
import {
|
||||||
|
decryptFile,
|
||||||
|
downloadEncryptedMedia,
|
||||||
|
downloadMedia,
|
||||||
|
mxcUrlToHttp,
|
||||||
|
} from '../../../utils/matrix';
|
||||||
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
|
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||||
|
import { ModalWide } from '../../../styles/Modal.css';
|
||||||
|
|
||||||
|
const renderErrorButton = (retry: () => void, text: string) => (
|
||||||
|
<TooltipProvider
|
||||||
|
tooltip={
|
||||||
|
<Tooltip variant="Critical">
|
||||||
|
<Text>Failed to load file!</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
position="Top"
|
||||||
|
align="Center"
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<Button
|
||||||
|
ref={triggerRef}
|
||||||
|
size="400"
|
||||||
|
variant="Critical"
|
||||||
|
fill="Soft"
|
||||||
|
outlined
|
||||||
|
radii="300"
|
||||||
|
onClick={retry}
|
||||||
|
before={<Icon size="100" src={Icons.Warning} filled />}
|
||||||
|
>
|
||||||
|
<Text size="B400" truncate>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
type RenderTextViewerProps = {
|
||||||
|
name: string;
|
||||||
|
text: string;
|
||||||
|
langName: string;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
type ReadTextFileProps = {
|
||||||
|
body: string;
|
||||||
|
mimeType: string;
|
||||||
|
url: string;
|
||||||
|
encInfo?: EncryptedAttachmentInfo;
|
||||||
|
renderViewer: (props: RenderTextViewerProps) => ReactNode;
|
||||||
|
};
|
||||||
|
export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: ReadTextFileProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const [textViewer, setTextViewer] = useState(false);
|
||||||
|
|
||||||
|
const [textState, loadText] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||||
|
// Always use current session's token to avoid stale tokens during account switches
|
||||||
|
const accessToken = getCurrentAccessToken();
|
||||||
|
const fileContent = encInfo
|
||||||
|
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
||||||
|
: await downloadMedia(mediaUrl, accessToken);
|
||||||
|
|
||||||
|
const text = fileContent.text();
|
||||||
|
setTextViewer(true);
|
||||||
|
return text;
|
||||||
|
}, [mx, useAuthentication, mimeType, encInfo, url])
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{textState.status === AsyncStatus.Success && (
|
||||||
|
<Overlay open={textViewer} backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setTextViewer(false),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Modal
|
||||||
|
className={ModalWide}
|
||||||
|
size="500"
|
||||||
|
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||||
|
>
|
||||||
|
{renderViewer({
|
||||||
|
name: body,
|
||||||
|
text: textState.data,
|
||||||
|
langName: READABLE_TEXT_MIME_TYPES.includes(mimeType)
|
||||||
|
? mimeTypeToExt(mimeType)
|
||||||
|
: mimeTypeToExt(READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)] ?? mimeType),
|
||||||
|
requestClose: () => setTextViewer(false),
|
||||||
|
})}
|
||||||
|
</Modal>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
)}
|
||||||
|
{textState.status === AsyncStatus.Error ? (
|
||||||
|
renderErrorButton(loadText, 'Open File')
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Solid"
|
||||||
|
radii="300"
|
||||||
|
size="400"
|
||||||
|
onClick={() =>
|
||||||
|
textState.status === AsyncStatus.Success ? setTextViewer(true) : loadText()
|
||||||
|
}
|
||||||
|
disabled={textState.status === AsyncStatus.Loading}
|
||||||
|
before={
|
||||||
|
textState.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner fill="Solid" size="100" variant="Secondary" />
|
||||||
|
) : (
|
||||||
|
<Icon size="100" src={Icons.ArrowRight} filled />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="B400" truncate>
|
||||||
|
Open File
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type RenderPdfViewerProps = {
|
||||||
|
name: string;
|
||||||
|
src: string;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
export type ReadPdfFileProps = {
|
||||||
|
body: string;
|
||||||
|
mimeType: string;
|
||||||
|
url: string;
|
||||||
|
encInfo?: EncryptedAttachmentInfo;
|
||||||
|
renderViewer: (props: RenderPdfViewerProps) => ReactNode;
|
||||||
|
};
|
||||||
|
export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: ReadPdfFileProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const [pdfViewer, setPdfViewer] = useState(false);
|
||||||
|
|
||||||
|
const [pdfState, loadPdf] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||||
|
// Always use current session's token to avoid stale tokens during account switches
|
||||||
|
const accessToken = getCurrentAccessToken();
|
||||||
|
const fileContent = encInfo
|
||||||
|
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
||||||
|
: await downloadMedia(mediaUrl, accessToken);
|
||||||
|
setPdfViewer(true);
|
||||||
|
return URL.createObjectURL(fileContent);
|
||||||
|
}, [mx, url, useAuthentication, mimeType, encInfo])
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{pdfState.status === AsyncStatus.Success && (
|
||||||
|
<Overlay open={pdfViewer} backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setPdfViewer(false),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Modal
|
||||||
|
className={ModalWide}
|
||||||
|
size="500"
|
||||||
|
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||||
|
>
|
||||||
|
{renderViewer({
|
||||||
|
name: body,
|
||||||
|
src: pdfState.data,
|
||||||
|
requestClose: () => setPdfViewer(false),
|
||||||
|
})}
|
||||||
|
</Modal>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
)}
|
||||||
|
{pdfState.status === AsyncStatus.Error ? (
|
||||||
|
renderErrorButton(loadPdf, 'Open PDF')
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Solid"
|
||||||
|
radii="300"
|
||||||
|
size="400"
|
||||||
|
onClick={() => (pdfState.status === AsyncStatus.Success ? setPdfViewer(true) : loadPdf())}
|
||||||
|
disabled={pdfState.status === AsyncStatus.Loading}
|
||||||
|
before={
|
||||||
|
pdfState.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner fill="Solid" size="100" variant="Secondary" />
|
||||||
|
) : (
|
||||||
|
<Icon size="100" src={Icons.ArrowRight} filled />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="B400" truncate>
|
||||||
|
Open PDF
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DownloadFileProps = {
|
||||||
|
body: string;
|
||||||
|
mimeType: string;
|
||||||
|
url: string;
|
||||||
|
info: IFileInfo;
|
||||||
|
encInfo?: EncryptedAttachmentInfo;
|
||||||
|
};
|
||||||
|
export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
||||||
|
const [downloadState, download] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||||
|
// Always use current session's token to avoid stale tokens during account switches
|
||||||
|
const accessToken = getCurrentAccessToken();
|
||||||
|
const fileContent = encInfo
|
||||||
|
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
||||||
|
: await downloadMedia(mediaUrl, accessToken);
|
||||||
|
|
||||||
|
await saveMediaBlob(fileContent, body);
|
||||||
|
return fileContent;
|
||||||
|
}, [mx, url, useAuthentication, mimeType, encInfo, body])
|
||||||
|
);
|
||||||
|
|
||||||
|
return downloadState.status === AsyncStatus.Error ? (
|
||||||
|
renderErrorButton(download, `Retry Download (${bytesToSize(info.size ?? 0)})`)
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
size="400"
|
||||||
|
onClick={() =>
|
||||||
|
downloadState.status === AsyncStatus.Success
|
||||||
|
? saveMediaBlob(downloadState.data, body)
|
||||||
|
: download()
|
||||||
|
}
|
||||||
|
disabled={downloadState.status === AsyncStatus.Loading}
|
||||||
|
before={
|
||||||
|
downloadState.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner fill="Soft" size="100" variant="Secondary" />
|
||||||
|
) : (
|
||||||
|
<Icon size="100" src={Icons.Download} filled />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="B400" truncate>{`Download (${bytesToSize(info.size ?? 0)})`}</Text>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileContentProps = {
|
||||||
|
body: string;
|
||||||
|
mimeType: string;
|
||||||
|
renderAsTextFile: () => ReactNode;
|
||||||
|
renderAsPdfFile: () => ReactNode;
|
||||||
|
};
|
||||||
|
export const FileContent = as<'div', FileContentProps>(
|
||||||
|
({ body, mimeType, renderAsTextFile, renderAsPdfFile, children, ...props }, ref) => (
|
||||||
|
<Box direction="Column" gap="300" {...props} ref={ref}>
|
||||||
|
{(READABLE_TEXT_MIME_TYPES.includes(mimeType) ||
|
||||||
|
READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)]) &&
|
||||||
|
renderAsTextFile()}
|
||||||
|
{mimeType === 'application/pdf' && renderAsPdfFile()}
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -220,6 +220,12 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
|
|||||||
const getEndedTouch = (evt: TouchEvent, touchId: number) =>
|
const getEndedTouch = (evt: TouchEvent, touchId: number) =>
|
||||||
Array.from(evt.changedTouches).find((touch) => touch.identifier === touchId);
|
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) => {
|
const handleTouchStart = (evt: TouchEvent) => {
|
||||||
if (gestureRef.current.phase !== 'idle') return;
|
if (gestureRef.current.phase !== 'idle') return;
|
||||||
|
|
||||||
@@ -232,7 +238,12 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
|
|||||||
const messageEl = findMessageElement(target);
|
const messageEl = findMessageElement(target);
|
||||||
const messageId = messageEl?.getAttribute('data-message-id') ?? null;
|
const messageId = messageEl?.getAttribute('data-message-id') ?? null;
|
||||||
|
|
||||||
const canStartBack = backEnabled && !shouldIgnoreBackTarget(target);
|
// 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 =
|
const canStartReply =
|
||||||
replyEnabled &&
|
replyEnabled &&
|
||||||
messageEl &&
|
messageEl &&
|
||||||
@@ -242,23 +253,11 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
|
|||||||
|
|
||||||
if (!canStartBack && !canStartReply) return;
|
if (!canStartBack && !canStartReply) return;
|
||||||
|
|
||||||
if (touch.clientX <= EDGE_WIDTH && canStartBack) {
|
const contentLeft = rootRef.current?.getBoundingClientRect().left ?? 0;
|
||||||
evt.preventDefault();
|
const nearContentLeftEdge =
|
||||||
gestureRef.current = {
|
touch.clientX >= contentLeft && touch.clientX <= contentLeft + EDGE_WIDTH;
|
||||||
phase: 'back',
|
|
||||||
touchId: touch.identifier,
|
|
||||||
startX: touch.clientX,
|
|
||||||
startY: touch.clientY,
|
|
||||||
messageEl: null,
|
|
||||||
transformEl: null,
|
|
||||||
messageId: null,
|
|
||||||
edgeBack: true,
|
|
||||||
offset: 0,
|
|
||||||
};
|
|
||||||
document.documentElement.classList.add('mobile-gesture-lock');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Prefer pending over immediate back+preventDefault so a tap still clicks.
|
||||||
gestureRef.current = {
|
gestureRef.current = {
|
||||||
phase: 'pending',
|
phase: 'pending',
|
||||||
touchId: touch.identifier,
|
touchId: touch.identifier,
|
||||||
@@ -267,7 +266,7 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
|
|||||||
messageEl: canStartReply ? messageEl : null,
|
messageEl: canStartReply ? messageEl : null,
|
||||||
transformEl: canStartReply && messageEl ? findReplyTransformTarget(messageEl) : null,
|
transformEl: canStartReply && messageEl ? findReplyTransformTarget(messageEl) : null,
|
||||||
messageId: canStartReply ? messageId : null,
|
messageId: canStartReply ? messageId : null,
|
||||||
edgeBack: false,
|
edgeBack: nearContentLeftEdge && canStartBack,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import * as swipeCss from '../mobile/mobile-gestures.css';
|
|||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
|
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
|
||||||
import { useShowCompactMasterView } from '../../hooks/useCompactNav';
|
import { useShowCompactMasterView } from '../../hooks/useCompactNav';
|
||||||
|
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||||
|
|
||||||
type PageRootProps = {
|
type PageRootProps = {
|
||||||
nav: ReactNode;
|
nav: ReactNode;
|
||||||
@@ -74,6 +75,7 @@ export function PageNav({
|
|||||||
grow={isMobile ? 'Yes' : undefined}
|
grow={isMobile ? 'Yes' : undefined}
|
||||||
className={classNames(css.PageNav({ size }), className)}
|
className={classNames(css.PageNav({ size }), className)}
|
||||||
shrink={isMobile ? 'Yes' : 'No'}
|
shrink={isMobile ? 'Yes' : 'No'}
|
||||||
|
data-page-nav=""
|
||||||
>
|
>
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
{children}
|
{children}
|
||||||
@@ -89,6 +91,7 @@ export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
|
|||||||
className={classNames(css.PageNavHeader({ outlined }), className)}
|
className={classNames(css.PageNavHeader({ outlined }), className)}
|
||||||
variant="Background"
|
variant="Background"
|
||||||
size="600"
|
size="600"
|
||||||
|
data-folder-tab="nav"
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
@@ -104,13 +107,16 @@ export function PageNavContent({
|
|||||||
scrollRef?: MutableRefObject<HTMLDivElement | null>;
|
scrollRef?: MutableRefObject<HTMLDivElement | null>;
|
||||||
scrollProps?: React.ComponentProps<typeof Scroll>;
|
scrollProps?: React.ComponentProps<typeof Scroll>;
|
||||||
}) {
|
}) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const hideScrollbar = isStationeryTheme(theme);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
<Scroll
|
<Scroll
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
variant="Background"
|
variant="Background"
|
||||||
direction="Vertical"
|
direction="Vertical"
|
||||||
size="300"
|
size={hideScrollbar ? '0' : '300'}
|
||||||
hideTrack
|
hideTrack
|
||||||
visibility="Hover"
|
visibility="Hover"
|
||||||
{...scrollProps}
|
{...scrollProps}
|
||||||
@@ -126,6 +132,7 @@ export const Page = as<'div'>(({ className, ...props }, ref) => (
|
|||||||
grow="Yes"
|
grow="Yes"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
className={classNames(ContainerColor({ variant: 'Surface' }), className)}
|
className={classNames(ContainerColor({ variant: 'Surface' }), className)}
|
||||||
|
data-page=""
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
@@ -137,6 +144,7 @@ export const PageHeader = as<'div', css.PageHeaderVariants>(
|
|||||||
as="header"
|
as="header"
|
||||||
size="600"
|
size="600"
|
||||||
className={classNames(css.PageHeader({ balance, outlined }), className)}
|
className={classNames(css.PageHeader({ balance, outlined }), className)}
|
||||||
|
data-folder-tab-bar=""
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
|
|||||||
74
overlay/src/app/components/video-viewer/VideoViewer.tsx
Normal file
74
overlay/src/app/components/video-viewer/VideoViewer.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { Box, Chip, Header, IconButton, Text, as } from 'folds';
|
||||||
|
import { Icon, Icons } from '../icons';
|
||||||
|
import * as css from './VideoViewer.css';
|
||||||
|
import { downloadAndSaveMedia } from '../../utils/saveMedia';
|
||||||
|
import { getCurrentAccessToken } from '../../utils/auth';
|
||||||
|
|
||||||
|
export type VideoViewerProps = {
|
||||||
|
alt: string;
|
||||||
|
src: string;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VideoViewer = as<'div', VideoViewerProps>(
|
||||||
|
({ className, alt, src, requestClose, ...props }, ref) => {
|
||||||
|
const handleDownload = async () => {
|
||||||
|
try {
|
||||||
|
await downloadAndSaveMedia(src, alt, getCurrentAccessToken());
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[VideoViewer] Failed to download media:', error);
|
||||||
|
try {
|
||||||
|
window.open(src, '_blank');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className={classNames(css.VideoViewer, className)}
|
||||||
|
direction="Column"
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Header className={css.VideoViewerHeader} size="400">
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<IconButton size="300" radii="300" onClick={requestClose}>
|
||||||
|
<Icon size="50" src={Icons.ArrowLeft} />
|
||||||
|
</IconButton>
|
||||||
|
<Text size="T300" truncate>
|
||||||
|
{alt}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No" alignItems="Center" gap="200">
|
||||||
|
<Chip
|
||||||
|
variant="Primary"
|
||||||
|
onClick={handleDownload}
|
||||||
|
radii="300"
|
||||||
|
before={<Icon size="50" src={Icons.Download} />}
|
||||||
|
>
|
||||||
|
<Text size="B300">Download</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
</Header>
|
||||||
|
<Box
|
||||||
|
grow="Yes"
|
||||||
|
className={css.VideoViewerContent}
|
||||||
|
justifyContent="Center"
|
||||||
|
alignItems="Center"
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
className={css.VideoViewerVideo}
|
||||||
|
src={src}
|
||||||
|
title={alt}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
|||||||
import { ThreadView } from './ThreadView';
|
import { ThreadView } from './ThreadView';
|
||||||
import { MobileSwipeToReplyLayer } from '../../components/mobile/MobileSwipeToReplyLayer';
|
import { MobileSwipeToReplyLayer } from '../../components/mobile/MobileSwipeToReplyLayer';
|
||||||
import { useMobileKeyboardLayout } from '../../hooks/useMobileKeyboardLayout';
|
import { useMobileKeyboardLayout } from '../../hooks/useMobileKeyboardLayout';
|
||||||
|
import * as composerCss from './room-composer-mobile.css';
|
||||||
|
|
||||||
const FN_KEYS_REGEX = /^F\d+$/;
|
const FN_KEYS_REGEX = /^F\d+$/;
|
||||||
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
||||||
@@ -64,7 +65,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||||||
const roomViewRef = useRef<HTMLDivElement>(null);
|
const roomViewRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
const { isLandscape, keyboardOpen } = useMobileKeyboardLayout();
|
const { immersive } = useMobileKeyboardLayout();
|
||||||
|
|
||||||
const { roomId } = room;
|
const { roomId } = room;
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
@@ -103,8 +104,9 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const composerPadding = isLandscape ? '0' : `0 ${config.space.S400}`;
|
const flushComposer = immersive;
|
||||||
const showHeader = !keyboardOpen;
|
const composerPadding = flushComposer ? '0' : `0 ${config.space.S400}`;
|
||||||
|
const showHeader = !immersive;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page ref={roomViewRef}>
|
<Page ref={roomViewRef}>
|
||||||
@@ -125,7 +127,20 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||||||
<RoomViewTyping room={room} />
|
<RoomViewTyping room={room} />
|
||||||
</Box>
|
</Box>
|
||||||
</MobileSwipeToReplyLayer>
|
</MobileSwipeToReplyLayer>
|
||||||
<Box shrink="No" direction="Column" data-disable-swipe-back="true">
|
<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 }}>
|
<div style={{ padding: composerPadding }}>
|
||||||
{tombstoneEvent ? (
|
{tombstoneEvent ? (
|
||||||
<RoomTombstone
|
<RoomTombstone
|
||||||
@@ -156,7 +171,12 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
|
{!immersive &&
|
||||||
|
(hideActivity ? (
|
||||||
|
<RoomViewFollowingPlaceholder />
|
||||||
|
) : (
|
||||||
|
<RoomViewFollowing room={room} />
|
||||||
|
))}
|
||||||
</Box>
|
</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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -802,8 +802,9 @@ export const Message = as<'div', MessageProps>(
|
|||||||
gap="300"
|
gap="300"
|
||||||
direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'}
|
direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'}
|
||||||
justifyContent="SpaceBetween"
|
justifyContent="SpaceBetween"
|
||||||
alignItems="Baseline"
|
alignItems="Center"
|
||||||
grow="Yes"
|
grow="Yes"
|
||||||
|
data-message-header=""
|
||||||
>
|
>
|
||||||
<Box alignItems="Center" gap="200">
|
<Box alignItems="Center" gap="200">
|
||||||
<Username
|
<Username
|
||||||
|
|||||||
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
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]
|
||||||
|
);
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { isCapacitorNative } from '../utils/tauri';
|
||||||
|
|
||||||
const KEYBOARD_HEIGHT_THRESHOLD_PX = 120;
|
const KEYBOARD_HEIGHT_THRESHOLD_PX = 120;
|
||||||
|
const IMMERSIVE_CLASS = 'immersive-landscape-keyboard';
|
||||||
|
|
||||||
const getIsLandscape = (): boolean => {
|
const getIsLandscape = (): boolean => {
|
||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === 'undefined') return false;
|
||||||
@@ -24,9 +26,25 @@ const getKeyboardOpen = (): boolean => {
|
|||||||
return tag === 'INPUT' || tag === 'TEXTAREA';
|
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 = {
|
export type MobileKeyboardLayout = {
|
||||||
isLandscape: boolean;
|
isLandscape: boolean;
|
||||||
keyboardOpen: boolean;
|
keyboardOpen: boolean;
|
||||||
|
/** Landscape + keyboard: compact immersive room chrome. */
|
||||||
|
immersive: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,6 +55,8 @@ export const useMobileKeyboardLayout = (): MobileKeyboardLayout => {
|
|||||||
const [isLandscape, setIsLandscape] = useState(getIsLandscape);
|
const [isLandscape, setIsLandscape] = useState(getIsLandscape);
|
||||||
const [keyboardOpen, setKeyboardOpen] = useState(getKeyboardOpen);
|
const [keyboardOpen, setKeyboardOpen] = useState(getKeyboardOpen);
|
||||||
|
|
||||||
|
const immersive = isLandscape && keyboardOpen;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sync = () => {
|
const sync = () => {
|
||||||
setIsLandscape(getIsLandscape());
|
setIsLandscape(getIsLandscape());
|
||||||
@@ -63,5 +83,15 @@ export const useMobileKeyboardLayout = (): MobileKeyboardLayout => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { isLandscape, keyboardOpen };
|
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 };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ type ClientLayoutProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ClientLayout({ nav, children }: ClientLayoutProps) {
|
export function ClientLayout({ nav, children }: ClientLayoutProps) {
|
||||||
const { keyboardOpen } = useMobileKeyboardLayout();
|
const { immersive } = useMobileKeyboardLayout();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
{!keyboardOpen && <Box shrink="No">{nav}</Box>}
|
{!immersive && <Box shrink="No">{nav}</Box>}
|
||||||
<Box grow="Yes">{children}</Box>
|
<Box grow="Yes">{children}</Box>
|
||||||
<DockedCallPanel />
|
<DockedCallPanel />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue, useStore } from 'jotai';
|
||||||
import { useAtom, useSetAtom } from 'jotai';
|
|
||||||
import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
|
import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
import { RoomEvent, RoomEventHandlerMap, MatrixClient, MatrixEvent, MatrixEventEvent } from 'matrix-js-sdk';
|
||||||
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
||||||
import LogoSVG from '../../../../public/res/svg/paarrot.svg';
|
import LogoSVG from '../../../../public/res/svg/paarrot.svg';
|
||||||
import LogoUnreadSVG from '../../../../public/res/svg/paarrot-unread.svg';
|
import LogoUnreadSVG from '../../../../public/res/svg/paarrot-unread.svg';
|
||||||
@@ -16,6 +15,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||||||
import { getDirectRoomPath, getHomeRoomPath, getSpaceRoomPath, getInboxInvitesPath } from '../pathUtils';
|
import { getDirectRoomPath, getHomeRoomPath, getSpaceRoomPath, getInboxInvitesPath } from '../pathUtils';
|
||||||
import {
|
import {
|
||||||
getMemberDisplayName,
|
getMemberDisplayName,
|
||||||
|
getMemberAvatarMxc,
|
||||||
getNotificationType,
|
getNotificationType,
|
||||||
getUnreadInfo,
|
getUnreadInfo,
|
||||||
isNotificationEvent,
|
isNotificationEvent,
|
||||||
@@ -23,13 +23,20 @@ import {
|
|||||||
guessPerfectParent,
|
guessPerfectParent,
|
||||||
} from '../../utils/room';
|
} from '../../utils/room';
|
||||||
import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
|
import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
|
||||||
import { getMxIdLocalPart, mxcUrlToHttp, getCanonicalAliasOrRoomId } from '../../utils/matrix';
|
import {
|
||||||
|
getMxIdLocalPart,
|
||||||
|
mxcUrlToHttp,
|
||||||
|
getCanonicalAliasOrRoomId,
|
||||||
|
encryptFile,
|
||||||
|
downloadMedia,
|
||||||
|
downloadEncryptedMedia,
|
||||||
|
decryptFile,
|
||||||
|
} from '../../utils/matrix';
|
||||||
import { mDirectAtom } from '../../state/mDirectList';
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||||
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { plainToEditorInput, toPlainText } from '../../components/editor';
|
|
||||||
import { ShareRoomPicker } from '../../features/ShareRoomPicker';
|
import { ShareRoomPicker } from '../../features/ShareRoomPicker';
|
||||||
import {
|
import {
|
||||||
isTauri,
|
isTauri,
|
||||||
@@ -37,19 +44,24 @@ import {
|
|||||||
isCapacitorNative,
|
isCapacitorNative,
|
||||||
sendNotification,
|
sendNotification,
|
||||||
setupNotificationTapListener,
|
setupNotificationTapListener,
|
||||||
|
clearNotificationsForRoom,
|
||||||
|
NOTIF_GROUP_DIRECTS,
|
||||||
|
NOTIF_GROUP_HOME,
|
||||||
|
type NotificationGroupInfo,
|
||||||
} from '../../utils/tauri';
|
} from '../../utils/tauri';
|
||||||
import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api';
|
import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api';
|
||||||
import {
|
import {
|
||||||
startBackgroundSync,
|
startBackgroundSync,
|
||||||
stopBackgroundSync,
|
stopBackgroundSync,
|
||||||
setAppForegroundState,
|
setAppForegroundState,
|
||||||
|
syncNotificationGroupMap,
|
||||||
} from '../../utils/backgroundSync';
|
} from '../../utils/backgroundSync';
|
||||||
import {
|
import {
|
||||||
TUploadItem,
|
TUploadItem,
|
||||||
roomIdToMsgDraftAtomFamily,
|
|
||||||
roomIdToUploadItemsAtomFamily,
|
roomIdToUploadItemsAtomFamily,
|
||||||
} from '../../state/room/roomInputDrafts';
|
} from '../../state/room/roomInputDrafts';
|
||||||
import { encryptFile } from '../../utils/matrix';
|
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
||||||
|
import { safeFile } from '../../utils/mimeTypes';
|
||||||
import {
|
import {
|
||||||
AndroidSharePayload,
|
AndroidSharePayload,
|
||||||
clearPendingAndroidShare,
|
clearPendingAndroidShare,
|
||||||
@@ -59,6 +71,162 @@ import {
|
|||||||
materializeSharedFile,
|
materializeSharedFile,
|
||||||
} from '../../utils/androidShare';
|
} from '../../utils/androidShare';
|
||||||
|
|
||||||
|
/** Wait briefly for an encrypted event to decrypt before reading its body. */
|
||||||
|
async function waitForEventDecryption(mx: MatrixClient, mEvent: MatrixEvent) {
|
||||||
|
if (!mEvent.isEncrypted() || mEvent.getClearContent() || mEvent.isDecryptionFailure()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const crypto = mx.getCrypto();
|
||||||
|
if (crypto) {
|
||||||
|
try {
|
||||||
|
await mEvent.attemptDecryption(crypto as any, { isRetry: true });
|
||||||
|
} catch {
|
||||||
|
// Decryption may complete asynchronously via the Decrypted listener below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mEvent.getClearContent() || mEvent.isDecryptionFailure()) return;
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
mEvent.removeListener(MatrixEventEvent.Decrypted, onDecrypted);
|
||||||
|
resolve();
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
const onDecrypted = () => {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
mEvent.removeListener(MatrixEventEvent.Decrypted, onDecrypted);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
mEvent.once(MatrixEventEvent.Decrypted, onDecrypted);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a human-readable notification body from (possibly decrypted) event content. */
|
||||||
|
function notificationBodyFromEvent(mEvent: MatrixEvent): string | undefined {
|
||||||
|
const content = mEvent.getClearContent() ?? mEvent.getContent();
|
||||||
|
const eventType = mEvent.getType();
|
||||||
|
|
||||||
|
if (eventType === 'm.reaction') {
|
||||||
|
const reactionKey = content['m.relates_to']?.key;
|
||||||
|
return reactionKey ? `reacted with ${reactionKey}` : 'reacted to a message';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === 'm.room.encrypted' || mEvent.isDecryptionFailure()) {
|
||||||
|
return 'Encrypted message';
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = typeof content.body === 'string' ? content.body : undefined;
|
||||||
|
switch (content.msgtype) {
|
||||||
|
case 'm.image':
|
||||||
|
return rawBody ? `📷 ${rawBody}` : '📷 Photo';
|
||||||
|
case 'm.video':
|
||||||
|
return rawBody ? `🎥 ${rawBody}` : '🎥 Video';
|
||||||
|
case 'm.audio':
|
||||||
|
return rawBody ? `🎵 ${rawBody}` : '🎵 Audio';
|
||||||
|
case 'm.file':
|
||||||
|
return rawBody ? `📎 ${rawBody}` : '📎 File';
|
||||||
|
case 'm.sticker':
|
||||||
|
return rawBody ? `🖼️ ${rawBody}` : '🖼️ Sticker';
|
||||||
|
default:
|
||||||
|
return rawBody;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download an authenticated media URL to a base64 string for native notification icons. */
|
||||||
|
async function mediaUrlToBase64(
|
||||||
|
url: string,
|
||||||
|
accessToken: string | null | undefined
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const blob = await downloadMedia(url, accessToken);
|
||||||
|
return blobToBase64(blob);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Notifications] Failed to fetch media for notification:', err);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function blobToBase64(blob: Blob): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const buffer = await blob.arrayBuffer();
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = '';
|
||||||
|
const chunk = 0x8000;
|
||||||
|
for (let i = 0; i < bytes.length; i += chunk) {
|
||||||
|
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
||||||
|
}
|
||||||
|
const mime = blob.type || 'image/jpeg';
|
||||||
|
return `data:${mime};base64,${btoa(binary)}`;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a sender display name from room membership, user directory, then MXID. */
|
||||||
|
function resolveSenderDisplayName(mx: MatrixClient, room: { roomId: string }, sender: string): string {
|
||||||
|
return (
|
||||||
|
getMemberDisplayName(room as any, sender) ??
|
||||||
|
mx.getUser(sender)?.displayName ??
|
||||||
|
getMxIdLocalPart(sender) ??
|
||||||
|
sender
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch image attachment preview (plaintext or encrypted) for the notification shade. */
|
||||||
|
async function fetchNotificationImageBase64(
|
||||||
|
mx: MatrixClient,
|
||||||
|
mEvent: MatrixEvent,
|
||||||
|
useAuthentication: boolean
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const content = (mEvent.getClearContent() ?? mEvent.getContent()) as Record<string, any>;
|
||||||
|
if (content.msgtype !== 'm.image' && mEvent.getType() !== 'm.sticker') return undefined;
|
||||||
|
|
||||||
|
const accessToken = mx.getAccessToken();
|
||||||
|
const info = content.info ?? {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prefer a smaller thumbnail when available.
|
||||||
|
if (info.thumbnail_file?.url) {
|
||||||
|
const mediaUrl =
|
||||||
|
mxcUrlToHttp(mx, info.thumbnail_file.url, useAuthentication) ?? info.thumbnail_file.url;
|
||||||
|
const blob = await downloadEncryptedMedia(
|
||||||
|
mediaUrl,
|
||||||
|
(encBuf) =>
|
||||||
|
decryptFile(encBuf, info.thumbnail_info?.mimetype ?? 'image/jpeg', info.thumbnail_file),
|
||||||
|
accessToken
|
||||||
|
);
|
||||||
|
return blobToBase64(blob);
|
||||||
|
}
|
||||||
|
if (typeof info.thumbnail_url === 'string') {
|
||||||
|
const mediaUrl =
|
||||||
|
mxcUrlToHttp(mx, info.thumbnail_url, useAuthentication, 512, 512, 'scale') ??
|
||||||
|
mxcUrlToHttp(mx, info.thumbnail_url, useAuthentication);
|
||||||
|
if (mediaUrl) return mediaUrlToBase64(mediaUrl, accessToken);
|
||||||
|
}
|
||||||
|
if (content.file?.url) {
|
||||||
|
const mediaUrl = mxcUrlToHttp(mx, content.file.url, useAuthentication) ?? content.file.url;
|
||||||
|
const blob = await downloadEncryptedMedia(
|
||||||
|
mediaUrl,
|
||||||
|
(encBuf) => decryptFile(encBuf, info.mimetype ?? 'image/jpeg', content.file),
|
||||||
|
accessToken
|
||||||
|
);
|
||||||
|
return blobToBase64(blob);
|
||||||
|
}
|
||||||
|
if (typeof content.url === 'string') {
|
||||||
|
const mediaUrl =
|
||||||
|
mxcUrlToHttp(mx, content.url, useAuthentication, 512, 512, 'scale') ??
|
||||||
|
mxcUrlToHttp(mx, content.url, useAuthentication);
|
||||||
|
if (mediaUrl) return mediaUrlToBase64(mediaUrl, accessToken);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Notifications] Failed to fetch image attachment for notification:', err);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the selected emoji style font to the document.
|
* Applies the selected emoji style font to the document.
|
||||||
* - System: Uses the native OS emoji font
|
* - System: Uses the native OS emoji font
|
||||||
@@ -223,15 +391,72 @@ function MessageNotifications() {
|
|||||||
|
|
||||||
// Set up notification tap listener for mobile
|
// Set up notification tap listener for mobile
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setupNotificationTapListener((path) => {
|
const openFromNotification = (target: string) => {
|
||||||
navigate(path);
|
if (!target) return;
|
||||||
|
|
||||||
|
if (target.startsWith('__room__:')) {
|
||||||
|
const roomId = target.slice('__room__:'.length);
|
||||||
|
if (!mx || !roomId) return;
|
||||||
|
try {
|
||||||
|
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
|
||||||
|
if (mDirects.has(roomId)) {
|
||||||
|
navigate(getDirectRoomPath(roomIdOrAlias));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const orphanParents = getOrphanParents(roomToParents, roomId);
|
||||||
|
if (orphanParents.length > 0) {
|
||||||
|
const parentSpace = guessPerfectParent(mx, roomId, orphanParents) ?? orphanParents[0];
|
||||||
|
const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace);
|
||||||
|
navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(getHomeRoomPath(roomIdOrAlias));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Notifications] Navigate from roomId error:', err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
setupNotificationTapListener(openFromNotification);
|
||||||
|
}, [navigate, mx, mDirects, roomToParents]);
|
||||||
|
|
||||||
|
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||||
|
const previousUnreadRoomsRef = useRef<Set<string>>(new Set());
|
||||||
|
const previousUnreadTotalsRef = useRef<Map<string, number>>(new Map());
|
||||||
|
|
||||||
|
// Dismiss OS notifications when a room's unread is cleared
|
||||||
|
// (local mark-as-read, or our receipt syncing from another device).
|
||||||
|
useEffect(() => {
|
||||||
|
const currentRooms = new Set(roomToUnread.keys());
|
||||||
|
const nextTotals = new Map<string, number>();
|
||||||
|
|
||||||
|
roomToUnread.forEach((unread, roomId) => {
|
||||||
|
nextTotals.set(roomId, unread.total);
|
||||||
|
const prevTotal = previousUnreadTotalsRef.current.get(roomId);
|
||||||
|
if (prevTotal !== undefined && prevTotal > 0 && unread.total === 0) {
|
||||||
|
void clearNotificationsForRoom(roomId);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, [navigate]);
|
|
||||||
|
for (const roomId of previousUnreadRoomsRef.current) {
|
||||||
|
if (!currentRooms.has(roomId)) {
|
||||||
|
void clearNotificationsForRoom(roomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousUnreadRoomsRef.current = currentRooms;
|
||||||
|
previousUnreadTotalsRef.current = nextTotals;
|
||||||
|
}, [roomToUnread]);
|
||||||
|
|
||||||
const notify = useCallback(
|
const notify = useCallback(
|
||||||
({
|
({
|
||||||
roomName,
|
roomName,
|
||||||
roomAvatar,
|
roomAvatar,
|
||||||
|
iconBase64,
|
||||||
|
bigPictureBase64,
|
||||||
username,
|
username,
|
||||||
messageBody,
|
messageBody,
|
||||||
roomId,
|
roomId,
|
||||||
@@ -240,14 +465,52 @@ function MessageNotifications() {
|
|||||||
}: {
|
}: {
|
||||||
roomName: string;
|
roomName: string;
|
||||||
roomAvatar?: string;
|
roomAvatar?: string;
|
||||||
|
iconBase64?: string;
|
||||||
|
bigPictureBase64?: string;
|
||||||
username: string;
|
username: string;
|
||||||
messageBody?: string;
|
messageBody?: string;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
isDm: boolean;
|
isDm: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const notificationTitle = username;
|
let group: NotificationGroupInfo;
|
||||||
const notificationBody = messageBody || 'New message';
|
if (isDm) {
|
||||||
|
group = {
|
||||||
|
groupId: NOTIF_GROUP_DIRECTS,
|
||||||
|
groupName: 'Direct messages',
|
||||||
|
kind: 'direct',
|
||||||
|
roomName,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const orphanParents = getOrphanParents(roomToParents, roomId);
|
||||||
|
if (orphanParents.length > 0 && mx) {
|
||||||
|
const parentSpace = guessPerfectParent(mx, roomId, orphanParents) ?? orphanParents[0];
|
||||||
|
const spaceRoom = mx.getRoom(parentSpace);
|
||||||
|
group = {
|
||||||
|
groupId: parentSpace,
|
||||||
|
groupName: spaceRoom?.name || 'Space',
|
||||||
|
kind: 'space',
|
||||||
|
roomName,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
group = {
|
||||||
|
groupId: NOTIF_GROUP_HOME,
|
||||||
|
groupName: 'Home',
|
||||||
|
kind: 'home',
|
||||||
|
roomName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMs: sender as title. Rooms: room name as title, sender in body.
|
||||||
|
const notificationTitle = isDm ? username : roomName || username;
|
||||||
|
const notificationBody = isDm
|
||||||
|
? messageBody || 'New message'
|
||||||
|
: messageBody
|
||||||
|
? `${username}: ${messageBody}`
|
||||||
|
: `${username} sent a message`;
|
||||||
|
const messageText = messageBody || 'New message';
|
||||||
|
const conversationTitle = isDm ? undefined : roomName || undefined;
|
||||||
|
|
||||||
/** Replicates TitleBar click navigation logic */
|
/** Replicates TitleBar click navigation logic */
|
||||||
const navigateToRoom = () => {
|
const navigateToRoom = () => {
|
||||||
@@ -292,7 +555,15 @@ function MessageNotifications() {
|
|||||||
sendNotification({
|
sendNotification({
|
||||||
title: notificationTitle,
|
title: notificationTitle,
|
||||||
body: notificationBody,
|
body: notificationBody,
|
||||||
|
senderName: username,
|
||||||
|
messageText,
|
||||||
|
conversationTitle,
|
||||||
path: roomPath,
|
path: roomPath,
|
||||||
|
roomId,
|
||||||
|
group,
|
||||||
|
icon: roomAvatar,
|
||||||
|
iconBase64,
|
||||||
|
bigPictureBase64,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
if (!window.closed) navigate(roomPath);
|
if (!window.closed) navigate(roomPath);
|
||||||
},
|
},
|
||||||
@@ -378,39 +649,54 @@ function MessageNotifications() {
|
|||||||
return;
|
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 (
|
if (
|
||||||
showNotifications &&
|
showNotifications &&
|
||||||
((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
|
((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
|
||||||
) {
|
) {
|
||||||
const avatarMxc =
|
void (async () => {
|
||||||
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
|
await waitForEventDecryption(mx, mEvent);
|
||||||
const content = mEvent.getContent();
|
|
||||||
|
const avatarMxc =
|
||||||
let messageBody: string | undefined;
|
getMemberAvatarMxc(room, sender) ??
|
||||||
if (mEvent.getType() === 'm.reaction') {
|
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ??
|
||||||
// For reactions, show "reacted with {emoji}"
|
room.getMxcAvatarUrl();
|
||||||
const reactionKey = content['m.relates_to']?.key;
|
const roomAvatar = avatarMxc
|
||||||
if (reactionKey) {
|
|
||||||
messageBody = `reacted with ${reactionKey}`;
|
|
||||||
} else {
|
|
||||||
messageBody = 'reacted to a message';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
messageBody = typeof content.body === 'string' ? content.body : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDm = room.getJoinedMemberCount() === 2 && !room.isSpaceRoom();
|
|
||||||
notify({
|
|
||||||
roomName: room.name ?? 'Unknown',
|
|
||||||
roomAvatar: avatarMxc
|
|
||||||
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
|
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||||
: undefined,
|
: undefined;
|
||||||
username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender,
|
|
||||||
messageBody,
|
const username = resolveSenderDisplayName(mx, room, sender);
|
||||||
roomId: room.roomId,
|
|
||||||
eventId,
|
const [iconBase64, bigPictureBase64] = await Promise.all([
|
||||||
isDm,
|
roomAvatar && isCapacitorNative()
|
||||||
});
|
? mediaUrlToBase64(roomAvatar, mx.getAccessToken())
|
||||||
|
: Promise.resolve(undefined),
|
||||||
|
isCapacitorNative()
|
||||||
|
? fetchNotificationImageBase64(mx, mEvent, useAuthentication)
|
||||||
|
: Promise.resolve(undefined),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const messageBody = notificationBodyFromEvent(mEvent);
|
||||||
|
|
||||||
|
notify({
|
||||||
|
roomName: room.name ?? 'Unknown',
|
||||||
|
roomAvatar,
|
||||||
|
iconBase64,
|
||||||
|
bigPictureBase64,
|
||||||
|
username,
|
||||||
|
messageBody,
|
||||||
|
roomId: room.roomId,
|
||||||
|
eventId,
|
||||||
|
isDm,
|
||||||
|
});
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notificationSound) {
|
if (notificationSound) {
|
||||||
@@ -430,6 +716,7 @@ function MessageNotifications() {
|
|||||||
notify,
|
notify,
|
||||||
selectedRoomId,
|
selectedRoomId,
|
||||||
useAuthentication,
|
useAuthentication,
|
||||||
|
mDirects,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -443,6 +730,9 @@ function MessageNotifications() {
|
|||||||
*/
|
*/
|
||||||
function BackgroundSyncSetup() {
|
function BackgroundSyncSetup() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
|
|
||||||
// Try to make a simple fetch to verify component is rendering
|
// Try to make a simple fetch to verify component is rendering
|
||||||
try {
|
try {
|
||||||
fetch('/_matrix/client/v3/sync', { method: 'HEAD' }).catch(() => {});
|
fetch('/_matrix/client/v3/sync', { method: 'HEAD' }).catch(() => {});
|
||||||
@@ -462,6 +752,49 @@ function BackgroundSyncSetup() {
|
|||||||
};
|
};
|
||||||
}, [mx]);
|
}, [mx]);
|
||||||
|
|
||||||
|
// Keep native background notifier informed of space → room grouping.
|
||||||
|
useEffect(() => {
|
||||||
|
const rooms: Record<
|
||||||
|
string,
|
||||||
|
{ groupId: string; groupName: string; roomName: string; kind: 'direct' | 'space' | 'home' }
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
for (const room of mx.getRooms()) {
|
||||||
|
if (room.isSpaceRoom()) continue;
|
||||||
|
const roomId = room.roomId;
|
||||||
|
const roomName = room.name || roomId;
|
||||||
|
if (mDirects.has(roomId)) {
|
||||||
|
rooms[roomId] = {
|
||||||
|
groupId: NOTIF_GROUP_DIRECTS,
|
||||||
|
groupName: 'Direct messages',
|
||||||
|
roomName,
|
||||||
|
kind: 'direct',
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const orphanParents = getOrphanParents(roomToParents, roomId);
|
||||||
|
if (orphanParents.length > 0) {
|
||||||
|
const parentSpace = guessPerfectParent(mx, roomId, orphanParents) ?? orphanParents[0];
|
||||||
|
const spaceRoom = mx.getRoom(parentSpace);
|
||||||
|
rooms[roomId] = {
|
||||||
|
groupId: parentSpace,
|
||||||
|
groupName: spaceRoom?.name || 'Space',
|
||||||
|
roomName,
|
||||||
|
kind: 'space',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
rooms[roomId] = {
|
||||||
|
groupId: NOTIF_GROUP_HOME,
|
||||||
|
groupName: 'Home',
|
||||||
|
roomName,
|
||||||
|
kind: 'home',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void syncNotificationGroupMap(rooms);
|
||||||
|
}, [mx, mDirects, roomToParents]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,10 +850,71 @@ function TaskbarFlashStopper() {
|
|||||||
|
|
||||||
function AndroidShareIntentHandler() {
|
function AndroidShareIntentHandler() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const store = useStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null);
|
const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null);
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
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(
|
const applyPendingShare = useCallback(
|
||||||
async (share: AndroidSharePayload, roomId: string) => {
|
async (share: AndroidSharePayload, roomId: string) => {
|
||||||
@@ -543,7 +937,7 @@ function AndroidShareIntentHandler() {
|
|||||||
if (share.files.length > 0) {
|
if (share.files.length > 0) {
|
||||||
for (const sharedFile of share.files) {
|
for (const sharedFile of share.files) {
|
||||||
const originalFile = await materializeSharedFile(sharedFile, share.receivedAt);
|
const originalFile = await materializeSharedFile(sharedFile, share.receivedAt);
|
||||||
|
|
||||||
let fileToUpload = originalFile;
|
let fileToUpload = originalFile;
|
||||||
let encInfo: any = undefined;
|
let encInfo: any = undefined;
|
||||||
|
|
||||||
@@ -594,6 +988,22 @@ function AndroidShareIntentHandler() {
|
|||||||
[mx]
|
[mx]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const ingestShare = useCallback(
|
||||||
|
(share: AndroidSharePayload) => {
|
||||||
|
const roomId = selectedRoomIdRef.current;
|
||||||
|
// Already in a room: attach to composer (Gboard GIF / paste / in-app share).
|
||||||
|
if (roomId && share.files.length > 0) {
|
||||||
|
queueShareAsDrafts(share, roomId).catch((err) => {
|
||||||
|
console.error('[AndroidShare] Failed to queue share into room:', err);
|
||||||
|
setPendingShare(share);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingShare(share);
|
||||||
|
},
|
||||||
|
[queueShareAsDrafts]
|
||||||
|
);
|
||||||
|
|
||||||
const handlePickRoom = useCallback(
|
const handlePickRoom = useCallback(
|
||||||
(roomId: string) => {
|
(roomId: string) => {
|
||||||
if (!pendingShare) return;
|
if (!pendingShare) return;
|
||||||
@@ -602,7 +1012,7 @@ function AndroidShareIntentHandler() {
|
|||||||
.then((applied) => {
|
.then((applied) => {
|
||||||
if (applied) {
|
if (applied) {
|
||||||
setPendingShare(null);
|
setPendingShare(null);
|
||||||
|
|
||||||
// Navigate to the selected room
|
// Navigate to the selected room
|
||||||
const isDirect = mDirects.has(roomId);
|
const isDirect = mDirects.has(roomId);
|
||||||
if (isDirect) {
|
if (isDirect) {
|
||||||
@@ -640,7 +1050,7 @@ function AndroidShareIntentHandler() {
|
|||||||
getPendingAndroidShare()
|
getPendingAndroidShare()
|
||||||
.then((share) => {
|
.then((share) => {
|
||||||
if (mounted && share) {
|
if (mounted && share) {
|
||||||
setPendingShare(share);
|
ingestShare(share);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -649,7 +1059,7 @@ function AndroidShareIntentHandler() {
|
|||||||
|
|
||||||
listenForAndroidShares((share) => {
|
listenForAndroidShares((share) => {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setPendingShare(share);
|
ingestShare(share);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then((handle) => {
|
.then((handle) => {
|
||||||
@@ -663,7 +1073,7 @@ function AndroidShareIntentHandler() {
|
|||||||
mounted = false;
|
mounted = false;
|
||||||
void listenerHandle?.remove();
|
void listenerHandle?.remove();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [ingestShare]);
|
||||||
|
|
||||||
if (!pendingShare) return null;
|
if (!pendingShare) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ interface AndroidShareHandlerPlugin {
|
|||||||
getPendingShare(): Promise<{ share: AndroidSharePayload | null }>;
|
getPendingShare(): Promise<{ share: AndroidSharePayload | null }>;
|
||||||
/** Clears the last pending share payload after it has been consumed. */
|
/** Clears the last pending share payload after it has been consumed. */
|
||||||
clearPendingShare(): Promise<void>;
|
clearPendingShare(): Promise<void>;
|
||||||
|
/** Save a base64 file to gallery / Downloads (MediaStore), or share-sheet fallback. */
|
||||||
|
saveFile(options: {
|
||||||
|
filename: string;
|
||||||
|
mimeType?: string;
|
||||||
|
base64: string;
|
||||||
|
}): Promise<{ saved: boolean; shared?: boolean; uri?: string }>;
|
||||||
|
/** Open the system share sheet for a base64 file. */
|
||||||
|
shareFile(options: {
|
||||||
|
filename: string;
|
||||||
|
mimeType?: string;
|
||||||
|
base64: string;
|
||||||
|
}): Promise<{ shared: boolean }>;
|
||||||
addListener(
|
addListener(
|
||||||
eventName: 'shareReceived',
|
eventName: 'shareReceived',
|
||||||
listenerFunc: (payload: AndroidSharePayload) => void
|
listenerFunc: (payload: AndroidSharePayload) => void
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ type UnifiedPushStatus = {
|
|||||||
distributors: string[] | string;
|
distributors: string[] | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ClearRoomNotificationsResult = {
|
||||||
|
cleared: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type UnifiedPushEndpointEvent = {
|
type UnifiedPushEndpointEvent = {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
previousEndpoint: string;
|
previousEndpoint: string;
|
||||||
@@ -48,6 +52,39 @@ interface MatrixBackgroundSyncPlugin {
|
|||||||
setAppForeground(options: { foreground: boolean }): Promise<void>;
|
setAppForeground(options: { foreground: boolean }): Promise<void>;
|
||||||
/** Returns fetch state and current UnifiedPush registration details. */
|
/** Returns fetch state and current UnifiedPush registration details. */
|
||||||
getStatus(): Promise<UnifiedPushStatus>;
|
getStatus(): Promise<UnifiedPushStatus>;
|
||||||
|
/** Cancel native tray notifications posted for a Matrix room. */
|
||||||
|
clearRoomNotifications(options: { roomId: string }): Promise<ClearRoomNotificationsResult>;
|
||||||
|
/**
|
||||||
|
* Persist room → space/group metadata so background notifications can nest
|
||||||
|
* under Direct messages / Space name / Home.
|
||||||
|
*/
|
||||||
|
setNotificationGroups(options: {
|
||||||
|
rooms: Record<
|
||||||
|
string,
|
||||||
|
{ groupId: string; groupName: string; roomName: string; kind: string }
|
||||||
|
>;
|
||||||
|
}): Promise<{ success: boolean }>;
|
||||||
|
/** Post a message notification with optional avatar (base64) from the JS layer. */
|
||||||
|
showNotification(options: {
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
senderName?: string;
|
||||||
|
messageText?: string;
|
||||||
|
conversationTitle?: string;
|
||||||
|
path?: string;
|
||||||
|
roomId: string;
|
||||||
|
groupId: string;
|
||||||
|
groupName: string;
|
||||||
|
kind: string;
|
||||||
|
largeIconBase64?: string;
|
||||||
|
bigPictureBase64?: string;
|
||||||
|
}): Promise<{ shown: boolean }>;
|
||||||
|
/** Pending navigation target from a native notification tap. */
|
||||||
|
getPendingNotificationNav(): Promise<{ path?: string | null; roomId?: string | null }>;
|
||||||
|
addListener(
|
||||||
|
eventName: 'notificationOpened',
|
||||||
|
listenerFunc: (event: { path?: string; roomId?: string }) => void
|
||||||
|
): Promise<PluginListenerHandle>;
|
||||||
addListener(
|
addListener(
|
||||||
eventName: 'unifiedPushNewEndpoint',
|
eventName: 'unifiedPushNewEndpoint',
|
||||||
listenerFunc: (event: UnifiedPushEndpointEvent) => void
|
listenerFunc: (event: UnifiedPushEndpointEvent) => void
|
||||||
@@ -516,3 +553,91 @@ export const getBackgroundSyncStatus = async (): Promise<UnifiedPushStatus | und
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears native (MatrixSyncService) tray notifications for a room after it is read.
|
||||||
|
* No-op when background sync / native Android is unavailable.
|
||||||
|
*/
|
||||||
|
export const clearNativeRoomNotifications = async (roomId: string): Promise<void> => {
|
||||||
|
if (!isBackgroundSyncSupported() || !roomId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await MatrixBackgroundSync.clearRoomNotifications({ roomId });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] clearRoomNotifications failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs space/DM grouping metadata to the native layer for background notifications.
|
||||||
|
*/
|
||||||
|
export const syncNotificationGroupMap = async (
|
||||||
|
rooms: Record<
|
||||||
|
string,
|
||||||
|
{ groupId: string; groupName: string; roomName: string; kind: string }
|
||||||
|
>
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!isBackgroundSyncSupported()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await MatrixBackgroundSync.setNotificationGroups({ rooms });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] setNotificationGroups failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts a native tray notification (supports dynamic/authenticated avatar icons).
|
||||||
|
* Prefer this over Capacitor LocalNotifications on Android.
|
||||||
|
*/
|
||||||
|
export const showNativeNotification = async (options: {
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
senderName?: string;
|
||||||
|
messageText?: string;
|
||||||
|
conversationTitle?: string;
|
||||||
|
path?: string;
|
||||||
|
roomId: string;
|
||||||
|
groupId: string;
|
||||||
|
groupName: string;
|
||||||
|
kind: string;
|
||||||
|
largeIconBase64?: string;
|
||||||
|
bigPictureBase64?: string;
|
||||||
|
}): Promise<boolean> => {
|
||||||
|
if (!isBackgroundSyncSupported()) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await MatrixBackgroundSync.showNotification(options);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] showNotification failed:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Consume a pending native notification navigation target, if any. */
|
||||||
|
export const getPendingNotificationNav = async (): Promise<{
|
||||||
|
path?: string | null;
|
||||||
|
roomId?: string | null;
|
||||||
|
}> => {
|
||||||
|
if (!isBackgroundSyncSupported()) return {};
|
||||||
|
try {
|
||||||
|
return await MatrixBackgroundSync.getPendingNotificationNav();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] getPendingNotificationNav failed:', err);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Subscribe to native notification taps that open the app. */
|
||||||
|
export const listenForNotificationOpens = async (
|
||||||
|
listener: (event: { path?: string; roomId?: string }) => void
|
||||||
|
): Promise<PluginListenerHandle | undefined> => {
|
||||||
|
if (!isBackgroundSyncSupported()) return undefined;
|
||||||
|
try {
|
||||||
|
return await MatrixBackgroundSync.addListener('notificationOpened', listener);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] notificationOpened listener failed:', err);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
103
overlay/src/app/utils/saveMedia.ts
Normal file
103
overlay/src/app/utils/saveMedia.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import FileSaver from 'file-saver';
|
||||||
|
import { Capacitor, registerPlugin } from '@capacitor/core';
|
||||||
|
import { downloadMedia } from './matrix';
|
||||||
|
import { getCurrentAccessToken } from './auth';
|
||||||
|
|
||||||
|
interface AndroidShareHandlerSavePlugin {
|
||||||
|
saveFile(options: {
|
||||||
|
filename: string;
|
||||||
|
mimeType?: string;
|
||||||
|
base64: string;
|
||||||
|
}): Promise<{ saved: boolean; shared?: boolean; uri?: string }>;
|
||||||
|
shareFile(options: {
|
||||||
|
filename: string;
|
||||||
|
mimeType?: string;
|
||||||
|
base64: string;
|
||||||
|
}): Promise<{ shared: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AndroidShareHandler = registerPlugin<AndroidShareHandlerSavePlugin>('AndroidShareHandler');
|
||||||
|
|
||||||
|
const isAndroidNative = (): boolean =>
|
||||||
|
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
|
||||||
|
|
||||||
|
/** Convert a Blob to raw base64 (no data: prefix). */
|
||||||
|
const blobToBase64 = (blob: Blob): Promise<string> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result;
|
||||||
|
if (typeof result !== 'string') {
|
||||||
|
reject(new Error('Failed to read blob as base64'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(result.includes(',') ? result.split(',')[1] : result);
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(reader.error ?? new Error('FileReader failed'));
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
|
||||||
|
const guessMimeFromName = (filename: string): string | undefined => {
|
||||||
|
const ext = filename.split('.').pop()?.toLowerCase();
|
||||||
|
if (!ext) return undefined;
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
png: 'image/png',
|
||||||
|
gif: 'image/gif',
|
||||||
|
webp: 'image/webp',
|
||||||
|
bmp: 'image/bmp',
|
||||||
|
svg: 'image/svg+xml',
|
||||||
|
mp4: 'video/mp4',
|
||||||
|
webm: 'video/webm',
|
||||||
|
mkv: 'video/x-matroska',
|
||||||
|
mov: 'video/quicktime',
|
||||||
|
mp3: 'audio/mpeg',
|
||||||
|
ogg: 'audio/ogg',
|
||||||
|
wav: 'audio/wav',
|
||||||
|
pdf: 'application/pdf',
|
||||||
|
txt: 'text/plain',
|
||||||
|
};
|
||||||
|
return map[ext];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a Blob to disk. On Capacitor Android, uses MediaStore via the native
|
||||||
|
* ShareHandler plugin (FileSaver / `<a download>` does not work in WebView).
|
||||||
|
* Elsewhere falls back to FileSaver.
|
||||||
|
*/
|
||||||
|
export const saveMediaBlob = async (blob: Blob, filename: string): Promise<void> => {
|
||||||
|
if (isAndroidNative()) {
|
||||||
|
const base64 = await blobToBase64(blob);
|
||||||
|
const mimeType = blob.type || guessMimeFromName(filename) || 'application/octet-stream';
|
||||||
|
await AndroidShareHandler.saveFile({ filename, mimeType, base64 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
FileSaver.saveAs(blob, filename);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch media (auth download or blob:/http URL) and save it via [saveMediaBlob].
|
||||||
|
*/
|
||||||
|
export const downloadAndSaveMedia = async (
|
||||||
|
src: string,
|
||||||
|
filename: string,
|
||||||
|
accessToken?: string | null
|
||||||
|
): Promise<void> => {
|
||||||
|
let blob: Blob;
|
||||||
|
try {
|
||||||
|
if (src.startsWith('blob:') || src.startsWith('data:')) {
|
||||||
|
const res = await fetch(src);
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch ${src}`);
|
||||||
|
blob = await res.blob();
|
||||||
|
} else {
|
||||||
|
blob = await downloadMedia(src, accessToken ?? getCurrentAccessToken());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[saveMedia] downloadMedia failed, trying fetch fallback:', error);
|
||||||
|
const res = await fetch(src);
|
||||||
|
if (!res.ok) throw error;
|
||||||
|
blob = await res.blob();
|
||||||
|
}
|
||||||
|
await saveMediaBlob(blob, filename);
|
||||||
|
};
|
||||||
@@ -207,6 +207,36 @@ export const setupNotificationTapListener = async (onTap: (path: string) => void
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to set up Capacitor notification tap listener:', err);
|
console.warn('Failed to set up Capacitor notification tap listener:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Native NotificationManager posts (MatrixBackgroundSync.showNotification) open the
|
||||||
|
// Activity with extras — LocalNotifications never sees those taps.
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
getPendingNotificationNav,
|
||||||
|
listenForNotificationOpens,
|
||||||
|
} = await import('./backgroundSync');
|
||||||
|
|
||||||
|
const deliver = (path?: string | null, roomId?: string | null) => {
|
||||||
|
if (path && notificationTapCallback) {
|
||||||
|
notificationTapCallback(path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (roomId && notificationTapCallback) {
|
||||||
|
// Encode a synthetic marker path that ClientNonUIFeatures can resolve.
|
||||||
|
notificationTapCallback(`__room__:${roomId}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pending = await getPendingNotificationNav();
|
||||||
|
deliver(pending.path, pending.roomId);
|
||||||
|
|
||||||
|
await listenForNotificationOpens((event) => {
|
||||||
|
void focusWindow();
|
||||||
|
deliver(event.path, event.roomId);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to set up native notification open listener:', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -351,24 +381,7 @@ const ensureNotificationChannel = async (): Promise<void> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ensureCapacitorNotificationChannel = async (): Promise<void> => {
|
const ensureCapacitorNotificationChannel = async (): Promise<void> => {
|
||||||
if (notificationChannelCreated || !isAndroid()) return;
|
await ensureCapacitorNotificationChannels();
|
||||||
|
|
||||||
try {
|
|
||||||
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
|
||||||
await LocalNotifications.createChannel({
|
|
||||||
id: 'messages',
|
|
||||||
name: 'Messages',
|
|
||||||
description: 'Message notifications from Matrix',
|
|
||||||
importance: 5,
|
|
||||||
sound: 'default',
|
|
||||||
visibility: 1,
|
|
||||||
vibration: true,
|
|
||||||
lights: true,
|
|
||||||
});
|
|
||||||
notificationChannelCreated = true;
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Failed to create Capacitor notification channel:', err);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -422,6 +435,225 @@ export const getSystemNotificationPermissionState = async (): Promise<Permission
|
|||||||
return 'denied';
|
return 'denied';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Foreground sync status notification — must not collide with room message IDs. */
|
||||||
|
const ANDROID_STATUS_NOTIFICATION_ID = 1001;
|
||||||
|
|
||||||
|
/** Android notification group keys for rooms outside a space. */
|
||||||
|
export const NOTIF_GROUP_DIRECTS = 'paarrot_directs';
|
||||||
|
export const NOTIF_GROUP_HOME = 'paarrot_home';
|
||||||
|
|
||||||
|
/** Capacitor / native channel ids (under the Paarrot app). */
|
||||||
|
export const NOTIF_CHANNEL_DIRECTS = 'messages_directs';
|
||||||
|
export const NOTIF_CHANNEL_SPACES = 'messages_spaces';
|
||||||
|
export const NOTIF_CHANNEL_HOME = 'messages_home';
|
||||||
|
/** Legacy channel — kept so older installs still receive. */
|
||||||
|
export const NOTIF_CHANNEL_MESSAGES_LEGACY = 'messages';
|
||||||
|
|
||||||
|
export type NotificationGroupKind = 'direct' | 'space' | 'home';
|
||||||
|
|
||||||
|
export type NotificationGroupInfo = {
|
||||||
|
groupId: string;
|
||||||
|
groupName: string;
|
||||||
|
kind: NotificationGroupKind;
|
||||||
|
roomName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable Android notification id for a Matrix room.
|
||||||
|
* Re-using the same id replaces the previous tray entry instead of stacking.
|
||||||
|
*/
|
||||||
|
export const notificationIdForRoom = (roomId: string): number => {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < roomId.length; i += 1) {
|
||||||
|
hash = (hash << 5) - hash + roomId.charCodeAt(i);
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
let id = Math.abs(hash) % 2147483647;
|
||||||
|
if (id === 0 || id === ANDROID_STATUS_NOTIFICATION_ID) {
|
||||||
|
id = ANDROID_STATUS_NOTIFICATION_ID + 1;
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stable id for a space/group summary notification. */
|
||||||
|
export const notificationIdForGroupSummary = (groupId: string): number =>
|
||||||
|
notificationIdForRoom(`summary:${groupId}`);
|
||||||
|
|
||||||
|
const channelIdForKind = (kind: NotificationGroupKind): string => {
|
||||||
|
switch (kind) {
|
||||||
|
case 'direct':
|
||||||
|
return NOTIF_CHANNEL_DIRECTS;
|
||||||
|
case 'space':
|
||||||
|
return NOTIF_CHANNEL_SPACES;
|
||||||
|
default:
|
||||||
|
return NOTIF_CHANNEL_HOME;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureCapacitorNotificationChannels = async (): Promise<void> => {
|
||||||
|
if (!isAndroid()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
||||||
|
const channels: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: NOTIF_CHANNEL_DIRECTS,
|
||||||
|
name: 'Direct messages',
|
||||||
|
description: 'One-to-one Matrix conversations',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: NOTIF_CHANNEL_SPACES,
|
||||||
|
name: 'Spaces',
|
||||||
|
description: 'Messages from rooms inside Spaces',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: NOTIF_CHANNEL_HOME,
|
||||||
|
name: 'Other rooms',
|
||||||
|
description: 'Rooms that are not in a Space',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: NOTIF_CHANNEL_MESSAGES_LEGACY,
|
||||||
|
name: 'Messages',
|
||||||
|
description: 'Legacy message channel',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
channels.map((channel) =>
|
||||||
|
LocalNotifications.createChannel({
|
||||||
|
id: channel.id,
|
||||||
|
name: channel.name,
|
||||||
|
description: channel.description,
|
||||||
|
importance: 5,
|
||||||
|
sound: 'default',
|
||||||
|
visibility: 1,
|
||||||
|
vibration: true,
|
||||||
|
lights: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
notificationChannelCreated = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to create Capacitor notification channels:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const notificationBelongsToRoom = (
|
||||||
|
notification: { id?: number; extra?: Record<string, unknown> },
|
||||||
|
roomId: string
|
||||||
|
): boolean => {
|
||||||
|
if (notification.id === notificationIdForRoom(roomId)) return true;
|
||||||
|
const extra = notification.extra;
|
||||||
|
if (!extra || typeof extra !== 'object') return false;
|
||||||
|
if (extra.roomId === roomId) return true;
|
||||||
|
const path = extra.path;
|
||||||
|
if (typeof path === 'string') {
|
||||||
|
return path.includes(encodeURIComponent(roomId)) || path.includes(roomId);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCapacitorGroupSummaryIfEmpty = async (
|
||||||
|
LocalNotifications: {
|
||||||
|
getDeliveredNotifications: () => Promise<{ notifications: Array<{ id?: number; extra?: Record<string, unknown>; group?: string }> }>;
|
||||||
|
cancel: (opts: { notifications: Array<{ id: number }> }) => Promise<void>;
|
||||||
|
removeDeliveredNotifications: (opts: { notifications: unknown[] }) => Promise<void>;
|
||||||
|
},
|
||||||
|
groupId: string
|
||||||
|
): Promise<void> => {
|
||||||
|
const summaryId = notificationIdForGroupSummary(groupId);
|
||||||
|
const delivered = await LocalNotifications.getDeliveredNotifications().catch(() => null);
|
||||||
|
if (!delivered?.notifications) return;
|
||||||
|
|
||||||
|
const siblings = delivered.notifications.filter((n) => {
|
||||||
|
if (n.id === summaryId) return false;
|
||||||
|
if (n.group === groupId) return true;
|
||||||
|
return n.extra?.groupId === groupId;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (siblings.length > 0) return;
|
||||||
|
|
||||||
|
await LocalNotifications.cancel({ notifications: [{ id: summaryId }] }).catch(() => undefined);
|
||||||
|
const summary = delivered.notifications.find((n) => n.id === summaryId);
|
||||||
|
if (summary) {
|
||||||
|
await LocalNotifications.removeDeliveredNotifications({
|
||||||
|
notifications: [summary],
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismiss system notifications tied to a room (after mark-as-read / receipt).
|
||||||
|
* Clears Capacitor LocalNotifications and native MatrixSyncService tray entries.
|
||||||
|
* Also drops the space/group summary when no child notifications remain.
|
||||||
|
*/
|
||||||
|
export const clearNotificationsForRoom = async (roomId: string): Promise<void> => {
|
||||||
|
if (!roomId) return;
|
||||||
|
|
||||||
|
if (isCapacitorNative()) {
|
||||||
|
try {
|
||||||
|
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
||||||
|
const id = notificationIdForRoom(roomId);
|
||||||
|
|
||||||
|
const deliveredBefore = await LocalNotifications.getDeliveredNotifications().catch(() => null);
|
||||||
|
const roomNotif = deliveredBefore?.notifications?.find((n: { id?: number; extra?: Record<string, unknown> }) =>
|
||||||
|
notificationBelongsToRoom(n, roomId)
|
||||||
|
);
|
||||||
|
const groupId =
|
||||||
|
(typeof roomNotif?.extra?.groupId === 'string' && roomNotif.extra.groupId) ||
|
||||||
|
(typeof (roomNotif as { group?: string } | undefined)?.group === 'string'
|
||||||
|
? (roomNotif as { group?: string }).group
|
||||||
|
: undefined);
|
||||||
|
|
||||||
|
await LocalNotifications.cancel({ notifications: [{ id }] }).catch(() => undefined);
|
||||||
|
|
||||||
|
if (deliveredBefore?.notifications?.length) {
|
||||||
|
const matching = deliveredBefore.notifications.filter((n: { id?: number; extra?: Record<string, unknown> }) =>
|
||||||
|
notificationBelongsToRoom(n, roomId)
|
||||||
|
);
|
||||||
|
if (matching.length > 0) {
|
||||||
|
await LocalNotifications.removeDeliveredNotifications({
|
||||||
|
notifications: matching,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupId) {
|
||||||
|
await clearCapacitorGroupSummaryIfEmpty(LocalNotifications, groupId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to clear Capacitor notifications for room:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { clearNativeRoomNotifications } = await import('./backgroundSync');
|
||||||
|
await clearNativeRoomNotifications(roomId);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to clear native room notifications:', err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTauri() && !isElectron()) {
|
||||||
|
try {
|
||||||
|
const { cancel, removeActive } = await import('@tauri-apps/plugin-notification');
|
||||||
|
const id = notificationIdForRoom(roomId);
|
||||||
|
if (typeof cancel === 'function') {
|
||||||
|
await cancel([id]).catch(() => undefined);
|
||||||
|
}
|
||||||
|
if (typeof removeActive === 'function') {
|
||||||
|
await removeActive([{ id }]).catch(() => undefined);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to clear Tauri notifications for room:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a notification using Tauri's notification plugin
|
* Send a notification using Tauri's notification plugin
|
||||||
* Falls back to browser Notification API if not in Tauri
|
* Falls back to browser Notification API if not in Tauri
|
||||||
@@ -430,10 +662,39 @@ export const sendNotification = async (options: {
|
|||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
iconBase64?: string;
|
||||||
|
bigPictureBase64?: string;
|
||||||
|
senderName?: string;
|
||||||
|
messageText?: string;
|
||||||
|
conversationTitle?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
|
roomId?: string;
|
||||||
|
group?: NotificationGroupInfo;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
const { title, body, icon, path, onClick } = options;
|
const {
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
icon,
|
||||||
|
iconBase64,
|
||||||
|
bigPictureBase64,
|
||||||
|
senderName,
|
||||||
|
messageText,
|
||||||
|
conversationTitle,
|
||||||
|
path,
|
||||||
|
roomId,
|
||||||
|
group,
|
||||||
|
onClick,
|
||||||
|
} = options;
|
||||||
|
const extra = {
|
||||||
|
...(path ? { path } : {}),
|
||||||
|
...(roomId ? { roomId } : {}),
|
||||||
|
...(group?.groupId ? { groupId: group.groupId, groupName: group.groupName, groupKind: group.kind } : {}),
|
||||||
|
};
|
||||||
|
const hasExtra = Object.keys(extra).length > 0;
|
||||||
|
const channelId = group ? channelIdForKind(group.kind) : NOTIF_CHANNEL_HOME;
|
||||||
|
const groupId = group?.groupId;
|
||||||
|
const groupName = group?.groupName;
|
||||||
|
|
||||||
// Use Electron's native notification API if in Electron
|
// Use Electron's native notification API if in Electron
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
@@ -475,11 +736,27 @@ export const sendNotification = async (options: {
|
|||||||
await tauriSendNotification({
|
await tauriSendNotification({
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
|
// Stable per-room id replaces prior tray entry instead of stacking
|
||||||
|
id: roomId ? notificationIdForRoom(roomId) : undefined,
|
||||||
// Use the channel on Android
|
// Use the channel on Android
|
||||||
channelId: isAndroid() ? 'messages' : undefined,
|
channelId: isAndroid() ? channelId : undefined,
|
||||||
// Store path in extra data for notification tap handling
|
group: groupId,
|
||||||
extra: path ? { path } : undefined,
|
icon,
|
||||||
|
// Store path/roomId in extra data for tap handling + clear-on-read
|
||||||
|
extra: hasExtra ? extra : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isAndroid() && groupId && groupName) {
|
||||||
|
await tauriSendNotification({
|
||||||
|
title: groupName,
|
||||||
|
body: 'New messages',
|
||||||
|
id: notificationIdForGroupSummary(groupId),
|
||||||
|
channelId,
|
||||||
|
group: groupId,
|
||||||
|
groupSummary: true,
|
||||||
|
extra: { groupId, groupName, isGroupSummary: true },
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -488,6 +765,31 @@ export const sendNotification = async (options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isCapacitorNative()) {
|
if (isCapacitorNative()) {
|
||||||
|
// Prefer native NotificationManager so we can set dynamic/authenticated avatars.
|
||||||
|
// Capacitor LocalNotifications only supports drawable resource largeIcon names.
|
||||||
|
if (roomId && groupId && groupName && group) {
|
||||||
|
try {
|
||||||
|
const { showNativeNotification } = await import('./backgroundSync');
|
||||||
|
const shown = await showNativeNotification({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
senderName: senderName ?? title,
|
||||||
|
messageText: messageText ?? body,
|
||||||
|
conversationTitle,
|
||||||
|
path,
|
||||||
|
roomId,
|
||||||
|
groupId,
|
||||||
|
groupName,
|
||||||
|
kind: group.kind,
|
||||||
|
largeIconBase64: iconBase64,
|
||||||
|
bigPictureBase64,
|
||||||
|
});
|
||||||
|
if (shown) return;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Native showNotification failed, falling back to LocalNotifications:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
||||||
let perm = await LocalNotifications.checkPermissions();
|
let perm = await LocalNotifications.checkPermissions();
|
||||||
@@ -496,21 +798,53 @@ export const sendNotification = async (options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (perm.display === 'granted') {
|
if (perm.display === 'granted') {
|
||||||
await ensureCapacitorNotificationChannel();
|
await ensureCapacitorNotificationChannels();
|
||||||
|
|
||||||
|
// One notification per room: same id updates the existing tray entry.
|
||||||
|
const id = roomId
|
||||||
|
? notificationIdForRoom(roomId)
|
||||||
|
: Math.floor(Date.now() % 2147483647);
|
||||||
|
|
||||||
|
const roomNotification: Record<string, unknown> = {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
channelId: isAndroid() ? channelId : undefined,
|
||||||
|
smallIcon: isAndroid() ? ANDROID_NOTIFICATION_SMALL_ICON : undefined,
|
||||||
|
iconColor: isAndroid() ? ANDROID_NOTIFICATION_ICON_COLOR : undefined,
|
||||||
|
extra: hasExtra ? extra : undefined,
|
||||||
|
threadIdentifier: groupId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isAndroid() && groupId) {
|
||||||
|
roomNotification.group = groupId;
|
||||||
|
if (groupName) {
|
||||||
|
roomNotification.summaryText = groupName;
|
||||||
|
}
|
||||||
|
if (group?.roomName && group.roomName !== title) {
|
||||||
|
roomNotification.largeBody = body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifications: Array<Record<string, unknown>> = [roomNotification];
|
||||||
|
|
||||||
|
// Nest rooms under a space / Directs / Home summary in the shade.
|
||||||
|
if (isAndroid() && groupId && groupName) {
|
||||||
|
notifications.push({
|
||||||
|
id: notificationIdForGroupSummary(groupId),
|
||||||
|
title: groupName,
|
||||||
|
body: 'New messages',
|
||||||
|
channelId,
|
||||||
|
smallIcon: ANDROID_NOTIFICATION_SMALL_ICON,
|
||||||
|
iconColor: ANDROID_NOTIFICATION_ICON_COLOR,
|
||||||
|
group: groupId,
|
||||||
|
groupSummary: true,
|
||||||
|
extra: { groupId, groupName, isGroupSummary: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const id = Math.floor(Date.now() % 2147483647);
|
|
||||||
await LocalNotifications.schedule({
|
await LocalNotifications.schedule({
|
||||||
notifications: [
|
notifications: notifications as any,
|
||||||
{
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
body,
|
|
||||||
channelId: isAndroid() ? 'messages' : undefined,
|
|
||||||
smallIcon: isAndroid() ? ANDROID_NOTIFICATION_SMALL_ICON : undefined,
|
|
||||||
iconColor: isAndroid() ? ANDROID_NOTIFICATION_ICON_COLOR : undefined,
|
|
||||||
extra: path ? { path } : undefined,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export const isCapacitorAndroid = Boolean(
|
|||||||
cap?.isNativePlatform?.() && cap?.getPlatform?.() === 'android'
|
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) {
|
if (isCapacitorAndroid) {
|
||||||
document.documentElement.classList.add('android-capacitor');
|
document.documentElement.classList.add('android-capacitor');
|
||||||
document.documentElement.style.setProperty(
|
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",
|
"name": "paarrot",
|
||||||
"version": "4.11.77",
|
"version": "4.11.121",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.77",
|
"version": "4.11.121",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.3.0",
|
"@capacitor/android": "8.3.0",
|
||||||
"@capacitor/cli": "8.3.0",
|
"@capacitor/cli": "8.3.0",
|
||||||
"@capacitor/core": "8.3.0",
|
"@capacitor/core": "8.3.0",
|
||||||
"@capacitor/local-notifications": "8.0.2"
|
"@capacitor/local-notifications": "8.0.2",
|
||||||
|
"@capacitor/status-bar": "8.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
@@ -82,6 +83,15 @@
|
|||||||
"@capacitor/core": ">=8.0.0"
|
"@capacitor/core": ">=8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@capacitor/status-bar": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-OR59dlbwvmrV5dKsC9lvwv48QaGbqcbSTBpk+9/WXWxXYSdXXdzJZU9p8oyNPAkuJhCdnSa3XmU43fZRPBJJ5w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.120",
|
"version": "4.11.139",
|
||||||
"description": "Paarrot - A Matrix client based on Cinny",
|
"description": "Paarrot - A Matrix client based on Cinny",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
@@ -25,7 +25,8 @@
|
|||||||
"@capacitor/android": "8.3.0",
|
"@capacitor/android": "8.3.0",
|
||||||
"@capacitor/cli": "8.3.0",
|
"@capacitor/cli": "8.3.0",
|
||||||
"@capacitor/core": "8.3.0",
|
"@capacitor/core": "8.3.0",
|
||||||
"@capacitor/local-notifications": "8.0.2"
|
"@capacitor/local-notifications": "8.0.2",
|
||||||
|
"@capacitor/status-bar": "8.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user