Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32af57d9f1 | ||
| 197474a049 | |||
| 19252fdbb0 | |||
|
|
b260f741d5 | ||
| 7655759bbc | |||
| d30b98c987 | |||
|
|
70d6f6ff42 | ||
| 97f0a15ae2 | |||
| 4cf19131bf | |||
|
|
83137c92a9 | ||
| 426ddf5650 | |||
| 7800e7ced7 | |||
|
|
a5afa6b493 | ||
| afeb45f59d | |||
| 6f45f94f19 | |||
|
|
fbcb291b9a | ||
| b7ec975fb0 | |||
|
|
3f5d5dec98 | ||
| c7b6e8f5f0 | |||
|
|
f709998c50 | ||
| ddcda65c1f | |||
| 6e32bf302a |
@@ -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,18 @@ public class MainActivity extends BridgeActivity {
|
|||||||
registerPlugin(ShareHandlerPlugin.class);
|
registerPlugin(ShareHandlerPlugin.class);
|
||||||
registerPlugin(PluginStoragePlugin.class);
|
registerPlugin(PluginStoragePlugin.class);
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
setupImageKeyboardSupport();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/** Advertise image MIME types and receive Gboard / paste / drag-drop media. */
|
||||||
|
private void setupImageKeyboardSupport() {
|
||||||
|
if (bridge == null) return;
|
||||||
|
WebView webView = bridge.getWebView();
|
||||||
|
if (webView == null) return;
|
||||||
|
ViewCompat.setOnReceiveContentListener(
|
||||||
|
webView,
|
||||||
|
ImageKeyboardWebView.MIME_TYPES,
|
||||||
|
new ImageKeyboardContentReceiver()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ object ShareIntentStore {
|
|||||||
|
|
||||||
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return
|
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return
|
||||||
|
|
||||||
val receivedAt = System.currentTimeMillis()
|
|
||||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||||
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
|
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
|
||||||
|
|
||||||
@@ -86,8 +85,29 @@ object ShareIntentStore {
|
|||||||
uri?.let { uris.add(it) }
|
uri?.let { uris.add(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deliverShare(context, uris, text, subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver content URIs from the IME (Gboard GIF), clipboard paste, or drag-drop
|
||||||
|
* into the same share pipeline used by ACTION_SEND.
|
||||||
|
*/
|
||||||
|
fun handleContentUris(context: Context, uris: List<Uri>) {
|
||||||
|
if (uris.isEmpty()) return
|
||||||
|
deliverShare(context, uris, text = null, subject = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deliverShare(
|
||||||
|
context: Context,
|
||||||
|
uris: List<Uri>,
|
||||||
|
text: String?,
|
||||||
|
subject: String?,
|
||||||
|
) {
|
||||||
|
val receivedAt = System.currentTimeMillis()
|
||||||
val copiedFiles = uris.mapNotNull { copySharedUri(context, it, receivedAt) }
|
val copiedFiles = uris.mapNotNull { copySharedUri(context, it, receivedAt) }
|
||||||
|
|
||||||
|
if (copiedFiles.isEmpty() && text.isNullOrBlank() && subject.isNullOrBlank()) return
|
||||||
|
|
||||||
val payload = SharePayload(
|
val payload = SharePayload(
|
||||||
text = text,
|
text = text,
|
||||||
subject = subject,
|
subject = subject,
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context="com.getcapacitor.BridgeActivity">
|
||||||
|
|
||||||
|
<com.paarrot.app.ImageKeyboardWebView
|
||||||
|
android:id="@+id/webview"
|
||||||
|
android:layout_width="fill_parent"
|
||||||
|
android:layout_height="fill_parent" />
|
||||||
|
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
{
|
{
|
||||||
"dependencies": {}
|
"dependencies": {
|
||||||
|
"@capacitor/status-bar": "8.0.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Outlet, useLocation } from 'react-router-dom';
|
import { Outlet, useLocation } from 'react-router-dom';
|
||||||
import { useCompactNav } from '../hooks/useCompactNav';
|
import { useCompactNav, useIsCompactListRoute } from '../hooks/useCompactNav';
|
||||||
import { MobileSwipeGestureHost } from './mobile/MobileSwipeGestureHost';
|
import { MobileSwipeGestureHost } from './mobile/MobileSwipeGestureHost';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper for Outlet that adds route-based animation
|
* Outlet wrapper for route remounts + compact swipe-back chrome.
|
||||||
* Forces remount on route change by using location as key
|
*
|
||||||
|
* On compact list routes the parent PageRoot already omits children. If this
|
||||||
|
* outlet is still mounted (stale stack / empty SpaceIndexRedirect), render
|
||||||
|
* nothing so an empty swipe shell cannot sit above the channel list and eat taps.
|
||||||
*/
|
*/
|
||||||
export function AnimatedOutlet() {
|
export function AnimatedOutlet() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const compact = useCompactNav();
|
const compact = useCompactNav();
|
||||||
|
const isListRoute = useIsCompactListRoute();
|
||||||
|
|
||||||
|
if (compact && isListRoute) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileSwipeGestureHost>
|
<MobileSwipeGestureHost>
|
||||||
|
|||||||
79
overlay/src/app/components/editor/Editor.css.ts
Normal file
79
overlay/src/app/components/editor/Editor.css.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, config, DefaultReset, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const Editor = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
overflow: 'hidden',
|
||||||
|
selectors: {
|
||||||
|
// Keyboard-flush dock: only a top edge so the input can sit on the IME.
|
||||||
|
'[data-composer-flush="true"] &': {
|
||||||
|
borderRadius: 0,
|
||||||
|
boxShadow: `inset 0 ${config.borderWidth.B300} 0 0 ${color.SurfaceVariant.ContainerLine}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const EditorOptions = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
padding: config.space.S200,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const EditorTextareaScroll = style({});
|
||||||
|
|
||||||
|
export const EditorTextarea = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
flexGrow: 1,
|
||||||
|
height: '100%',
|
||||||
|
padding: `${toRem(13)} ${toRem(1)}`,
|
||||||
|
selectors: {
|
||||||
|
[`${EditorTextareaScroll}:first-child &`]: {
|
||||||
|
paddingLeft: toRem(13),
|
||||||
|
},
|
||||||
|
[`${EditorTextareaScroll}:last-child &`]: {
|
||||||
|
paddingRight: toRem(13),
|
||||||
|
},
|
||||||
|
'&:focus': {
|
||||||
|
outline: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const EditorPlaceholderContainer = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
opacity: config.opacity.Placeholder,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const EditorPlaceholderTextVisual = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
display: 'block',
|
||||||
|
paddingTop: toRem(13),
|
||||||
|
paddingLeft: toRem(1),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const EditorToolbarBase = style({
|
||||||
|
padding: `0 ${config.borderWidth.B300}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const EditorToolbar = style({
|
||||||
|
padding: config.space.S100,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MarkdownBtnBox = style({
|
||||||
|
paddingRight: config.space.S100,
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useCompactNav } from '../../hooks/useCompactNav';
|
import { useCompactNav } from '../../hooks/useCompactNav';
|
||||||
import { useBackRoute } from '../../hooks/useBackRoute';
|
import { useBackRoute } from '../../hooks/useBackRoute';
|
||||||
import { startReplyToEvent } from '../../features/room/replyToMessage';
|
import { startReplyToEvent } from '../../features/room/replyToMessage';
|
||||||
@@ -93,6 +94,7 @@ const resetElementTransform = (el: HTMLElement | null) => {
|
|||||||
|
|
||||||
export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps) {
|
export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps) {
|
||||||
const compact = useCompactNav();
|
const compact = useCompactNav();
|
||||||
|
const { pathname } = useLocation();
|
||||||
const { canGoBack, goBack } = useBackRoute();
|
const { canGoBack, goBack } = useBackRoute();
|
||||||
const backEnabled = compact && canGoBack;
|
const backEnabled = compact && canGoBack;
|
||||||
const replyEnabled = compact;
|
const replyEnabled = compact;
|
||||||
@@ -350,7 +352,7 @@ export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetGesture();
|
resetGesture();
|
||||||
}, [canGoBack, resetGesture]);
|
}, [canGoBack, pathname, resetGesture]);
|
||||||
|
|
||||||
if (!compact) {
|
if (!compact) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
import { color, config, toRem } from 'folds';
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
export const SwipeBackRoot = style({
|
export const SwipeBackRoot = style({
|
||||||
@@ -36,7 +36,10 @@ export const CompactNavUnderlay = style({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
zIndex: 0,
|
zIndex: 0,
|
||||||
pointerEvents: 'none',
|
// Keep underlay tappable if the detail layer is empty/transparent
|
||||||
|
// (e.g. space index after leaving DMs). Opaque detail content still
|
||||||
|
// sits above and receives taps first.
|
||||||
|
pointerEvents: 'auto',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CompactDetailLayer = style({
|
export const CompactDetailLayer = style({
|
||||||
@@ -45,6 +48,19 @@ export const CompactDetailLayer = style({
|
|||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
// Pass through taps when outlet content is null/empty; real pages
|
||||||
|
// re-enable pointer events on their root via the child rule below.
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${CompactDetailLayer} > *`, {
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
backgroundColor: color.Background.Container,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SwipeToReplyLayer = style({
|
export const SwipeToReplyLayer = style({
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export function PageRoot({ nav, children }: PageRootProps) {
|
|||||||
const showCompactMaster = useShowCompactMasterView();
|
const showCompactMaster = useShowCompactMasterView();
|
||||||
const compact = screenSize === ScreenSize.Mobile;
|
const compact = screenSize === ScreenSize.Mobile;
|
||||||
|
|
||||||
|
// Master list routes: only the channel/space list is interactive.
|
||||||
|
// Never mount the detail/underlay stack here — an empty detail layer would
|
||||||
|
// sit above the list with pointer-events and freeze taps (DM → space bug).
|
||||||
if (compact && showCompactMaster) {
|
if (compact && showCompactMaster) {
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
|
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import { useRoomCreators } from '../../hooks/useRoomCreators';
|
|||||||
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
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 * 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 => {
|
||||||
@@ -63,6 +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 { immersive } = useMobileKeyboardLayout();
|
||||||
|
|
||||||
const { roomId } = room;
|
const { roomId } = room;
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
@@ -101,9 +104,13 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const flushComposer = immersive;
|
||||||
|
const composerPadding = flushComposer ? '0' : `0 ${config.space.S400}`;
|
||||||
|
const showHeader = !immersive;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page ref={roomViewRef}>
|
<Page ref={roomViewRef}>
|
||||||
<RoomViewHeader />
|
{showHeader && <RoomViewHeader />}
|
||||||
{activeThreadId ? (
|
{activeThreadId ? (
|
||||||
<ThreadView room={room} threadRootId={activeThreadId} />
|
<ThreadView room={room} threadRootId={activeThreadId} />
|
||||||
) : (
|
) : (
|
||||||
@@ -120,8 +127,21 @@ 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
|
||||||
<div style={{ padding: `0 ${config.space.S400}` }}>
|
shrink="No"
|
||||||
|
direction="Column"
|
||||||
|
data-disable-swipe-back="true"
|
||||||
|
data-composer-flush={flushComposer ? 'true' : undefined}
|
||||||
|
className={composerCss.ComposerDock}
|
||||||
|
>
|
||||||
|
{immersive && !hideActivity && (
|
||||||
|
<div className={composerCss.FollowingFloat}>
|
||||||
|
<div className={composerCss.FollowingFloatHit}>
|
||||||
|
<RoomViewFollowing room={room} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ padding: composerPadding }}>
|
||||||
{tombstoneEvent ? (
|
{tombstoneEvent ? (
|
||||||
<RoomTombstone
|
<RoomTombstone
|
||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
@@ -151,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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
63
overlay/src/app/features/room/room-composer-mobile.css.ts
Normal file
63
overlay/src/app/features/room/room-composer-mobile.css.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { recipe } from '@vanilla-extract/recipes';
|
||||||
|
import { DefaultReset, color, config } from 'folds';
|
||||||
|
|
||||||
|
export const ComposerDock = style({
|
||||||
|
position: 'relative',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Read receipts float above the input so they don't reserve layout height. */
|
||||||
|
export const FollowingFloat = style({
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '100%',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 2,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
padding: `0 ${config.space.S200}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FollowingFloatHit = style({
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
maxWidth: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RoomViewFollowingPlaceholder = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
display: 'none',
|
||||||
|
height: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const RoomViewFollowing = recipe({
|
||||||
|
base: [
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
minHeight: 0,
|
||||||
|
padding: `${config.space.S100} ${config.space.S200}`,
|
||||||
|
width: 'auto',
|
||||||
|
maxWidth: '100%',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
outline: 'none',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
variants: {
|
||||||
|
clickable: {
|
||||||
|
true: {
|
||||||
|
cursor: 'pointer',
|
||||||
|
selectors: {
|
||||||
|
'&:hover, &:focus-visible': {
|
||||||
|
color: color.Primary.Main,
|
||||||
|
},
|
||||||
|
'&:active': {
|
||||||
|
color: color.Primary.Main,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
30
overlay/src/app/hooks/useCompactNav.ts
Normal file
30
overlay/src/app/hooks/useCompactNav.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { matchPath, useLocation } from 'react-router-dom';
|
||||||
|
import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from '../pages/paths';
|
||||||
|
import { ScreenSize, useScreenSizeContext } from './useScreenSize';
|
||||||
|
|
||||||
|
/** Skinny window layout (≤750px): master-detail instead of 3-column desktop. */
|
||||||
|
export const useCompactNav = (): boolean => {
|
||||||
|
const screenSize = useScreenSizeContext();
|
||||||
|
return screenSize === ScreenSize.Mobile;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIST_ROUTE_PATHS = [HOME_PATH, DIRECT_PATH, SPACE_PATH, EXPLORE_PATH, INBOX_PATH] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes where sidebar + channel list are shown together (no room/content pane).
|
||||||
|
* Uses matchPath on the current pathname so trailing-slash / encoding quirks
|
||||||
|
* after DM → space navigations still count as master list routes.
|
||||||
|
*/
|
||||||
|
export const useIsCompactListRoute = (): boolean => {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
return LIST_ROUTE_PATHS.some(
|
||||||
|
(path) => matchPath({ path, caseSensitive: true, end: true }, pathname) != null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useShowCompactMasterView = (): boolean => {
|
||||||
|
const compact = useCompactNav();
|
||||||
|
const isListRoute = useIsCompactListRoute();
|
||||||
|
return compact && isListRoute;
|
||||||
|
};
|
||||||
111
overlay/src/app/hooks/useFilePasteHandler.ts
Normal file
111
overlay/src/app/hooks/useFilePasteHandler.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useCallback, ClipboardEventHandler } from 'react';
|
||||||
|
import { getDataTransferFiles } from '../utils/dom';
|
||||||
|
import { readClipboardImage, isTauri, isLinux, isCapacitorNative } from '../utils/tauri';
|
||||||
|
|
||||||
|
const filesFromClipboardItems = (data: DataTransfer | null): File[] => {
|
||||||
|
if (!data?.items) return [];
|
||||||
|
|
||||||
|
const files: File[] = [];
|
||||||
|
for (let i = 0; i < data.items.length; i += 1) {
|
||||||
|
const item = data.items[i];
|
||||||
|
if (item.kind !== 'file') continue;
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) files.push(file);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filesFromHtmlImage = (data: DataTransfer | null): File[] => {
|
||||||
|
const html = data?.getData('text/html');
|
||||||
|
if (!html) return [];
|
||||||
|
|
||||||
|
const match = html.match(/<img[^>]+src=["'](data:image\/[a-zA-Z+]+;base64,[^"']+)["']/i);
|
||||||
|
if (!match?.[1]) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dataUrl = match[1];
|
||||||
|
const [meta, base64] = dataUrl.split(',');
|
||||||
|
const mime = meta.match(/data:(image\/[a-zA-Z+]+);base64/i)?.[1] ?? 'image/png';
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i += 1) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const ext = mime.split('/')[1]?.replace('+xml', '') || 'png';
|
||||||
|
return [new File([bytes], `clipboard-image.${ext}`, { type: mime })];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getClipboardFiles = (data: DataTransfer | null): File[] => {
|
||||||
|
if (!data) return [];
|
||||||
|
|
||||||
|
const fromFiles = getDataTransferFiles(data);
|
||||||
|
if (fromFiles && fromFiles.length > 0) return fromFiles;
|
||||||
|
|
||||||
|
const fromItems = filesFromClipboardItems(data);
|
||||||
|
if (fromItems.length > 0) return fromItems;
|
||||||
|
|
||||||
|
return filesFromHtmlImage(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Async Clipboard API fallback (when paste event has no file payload). */
|
||||||
|
const filesFromClipboardApi = async (): Promise<File[]> => {
|
||||||
|
if (!navigator.clipboard?.read) return [];
|
||||||
|
try {
|
||||||
|
const items = await navigator.clipboard.read();
|
||||||
|
const files: File[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
for (const type of item.types) {
|
||||||
|
if (!type.startsWith('image/')) continue;
|
||||||
|
const blob = await item.getType(type);
|
||||||
|
const ext = type.split('/')[1]?.replace('+xml', '') || 'png';
|
||||||
|
files.push(new File([blob], `clipboard-image.${ext}`, { type }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paste handler for RoomInput. Supports clipboard images via files, DataTransferItem
|
||||||
|
* list, HTML data-URL fallbacks, and Clipboard API (common on mobile WebViews).
|
||||||
|
* On Capacitor Android, native OnReceiveContent also routes images into the share pipeline.
|
||||||
|
*/
|
||||||
|
export const useFilePasteHandler = (onPaste: (file: File[]) => void): ClipboardEventHandler =>
|
||||||
|
useCallback(
|
||||||
|
async (evt) => {
|
||||||
|
const files = getClipboardFiles(evt.clipboardData);
|
||||||
|
if (files.length > 0) {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
onPaste(files);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On Linux with Tauri, browser clipboard API doesn't work for images.
|
||||||
|
if (isTauri() && isLinux()) {
|
||||||
|
const clipboardImage = await readClipboardImage();
|
||||||
|
if (clipboardImage) {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
onPaste([clipboardImage]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile WebView / Capacitor: paste events often omit file payloads.
|
||||||
|
if (isCapacitorNative()) {
|
||||||
|
const apiFiles = await filesFromClipboardApi();
|
||||||
|
if (apiFiles.length > 0) {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
onPaste(apiFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onPaste]
|
||||||
|
);
|
||||||
97
overlay/src/app/hooks/useMobileKeyboardLayout.ts
Normal file
97
overlay/src/app/hooks/useMobileKeyboardLayout.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { isCapacitorNative } from '../utils/tauri';
|
||||||
|
|
||||||
|
const KEYBOARD_HEIGHT_THRESHOLD_PX = 120;
|
||||||
|
const IMMERSIVE_CLASS = 'immersive-landscape-keyboard';
|
||||||
|
|
||||||
|
const getIsLandscape = (): boolean => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
if (window.matchMedia?.('(orientation: landscape)').matches) return true;
|
||||||
|
return window.innerWidth > window.innerHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getKeyboardOpen = (): boolean => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
|
||||||
|
const viewport = window.visualViewport;
|
||||||
|
if (viewport) {
|
||||||
|
const obscured = window.innerHeight - viewport.height - viewport.offsetTop;
|
||||||
|
if (obscured > KEYBOARD_HEIGHT_THRESHOLD_PX) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (!(active instanceof HTMLElement)) return false;
|
||||||
|
if (active.isContentEditable) return true;
|
||||||
|
const tag = active.tagName;
|
||||||
|
return tag === 'INPUT' || tag === 'TEXTAREA';
|
||||||
|
};
|
||||||
|
|
||||||
|
const setNativeStatusBarHidden = async (hidden: boolean) => {
|
||||||
|
if (!isCapacitorNative()) return;
|
||||||
|
try {
|
||||||
|
const { StatusBar } = await import('@capacitor/status-bar');
|
||||||
|
if (hidden) {
|
||||||
|
await StatusBar.hide();
|
||||||
|
} else {
|
||||||
|
await StatusBar.show();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Plugin may be missing in some builds; CSS safe-area collapse still applies.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MobileKeyboardLayout = {
|
||||||
|
isLandscape: boolean;
|
||||||
|
keyboardOpen: boolean;
|
||||||
|
/** Landscape + keyboard: compact immersive room chrome. */
|
||||||
|
immersive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Landscape + soft-keyboard layout signals for compact room chrome.
|
||||||
|
* Uses visualViewport when available (Capacitor/Android WebView).
|
||||||
|
*/
|
||||||
|
export const useMobileKeyboardLayout = (): MobileKeyboardLayout => {
|
||||||
|
const [isLandscape, setIsLandscape] = useState(getIsLandscape);
|
||||||
|
const [keyboardOpen, setKeyboardOpen] = useState(getKeyboardOpen);
|
||||||
|
|
||||||
|
const immersive = isLandscape && keyboardOpen;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sync = () => {
|
||||||
|
setIsLandscape(getIsLandscape());
|
||||||
|
setKeyboardOpen(getKeyboardOpen());
|
||||||
|
};
|
||||||
|
|
||||||
|
sync();
|
||||||
|
|
||||||
|
const orientationQuery = window.matchMedia?.('(orientation: landscape)');
|
||||||
|
orientationQuery?.addEventListener?.('change', sync);
|
||||||
|
window.addEventListener('resize', sync);
|
||||||
|
window.visualViewport?.addEventListener('resize', sync);
|
||||||
|
window.visualViewport?.addEventListener('scroll', sync);
|
||||||
|
window.addEventListener('focusin', sync);
|
||||||
|
window.addEventListener('focusout', sync);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
orientationQuery?.removeEventListener?.('change', sync);
|
||||||
|
window.removeEventListener('resize', sync);
|
||||||
|
window.visualViewport?.removeEventListener('resize', sync);
|
||||||
|
window.visualViewport?.removeEventListener('scroll', sync);
|
||||||
|
window.removeEventListener('focusin', sync);
|
||||||
|
window.removeEventListener('focusout', sync);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.toggle(IMMERSIVE_CLASS, immersive);
|
||||||
|
void setNativeStatusBarHidden(immersive);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.documentElement.classList.remove(IMMERSIVE_CLASS);
|
||||||
|
void setNativeStatusBarHidden(false);
|
||||||
|
};
|
||||||
|
}, [immersive]);
|
||||||
|
|
||||||
|
return { isLandscape, keyboardOpen, immersive };
|
||||||
|
};
|
||||||
57
overlay/src/app/hooks/useViewTransitions.ts
Normal file
57
overlay/src/app/hooks/useViewTransitions.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import { useLocation, useNavigate, NavigateOptions } from 'react-router-dom';
|
||||||
|
import { supportsViewTransitions, withViewTransition } from '../utils/viewTransitions';
|
||||||
|
|
||||||
|
export const useViewTransitions = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!supportsViewTransitions()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.classList.add('view-transitions-enabled');
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.documentElement.classList.remove('view-transitions-enabled');
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Route changed
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
supportsViewTransitions: supportsViewTransitions(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate helper. On Capacitor / coarse-pointer, View Transitions are disabled
|
||||||
|
* so DM ↔ space switches cannot leave a stuck hit-blocking overlay.
|
||||||
|
*/
|
||||||
|
export const useNavigateWithTransition = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
(to: string | number, options?: NavigateOptions) => {
|
||||||
|
if (!supportsViewTransitions()) {
|
||||||
|
if (typeof to === 'number') {
|
||||||
|
navigate(to);
|
||||||
|
} else {
|
||||||
|
navigate(to, options);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
withViewTransition(() => {
|
||||||
|
if (typeof to === 'number') {
|
||||||
|
navigate(to);
|
||||||
|
} else {
|
||||||
|
navigate(to, options);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[navigate]
|
||||||
|
);
|
||||||
|
};
|
||||||
21
overlay/src/app/pages/client/ClientLayout.tsx
Normal file
21
overlay/src/app/pages/client/ClientLayout.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import { Box } from 'folds';
|
||||||
|
import { DockedCallPanel } from '../../features/call/DockedCallPanel';
|
||||||
|
import { useMobileKeyboardLayout } from '../../hooks/useMobileKeyboardLayout';
|
||||||
|
|
||||||
|
type ClientLayoutProps = {
|
||||||
|
nav: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ClientLayout({ nav, children }: ClientLayoutProps) {
|
||||||
|
const { immersive } = useMobileKeyboardLayout();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box grow="Yes">
|
||||||
|
{!immersive && <Box shrink="No">{nav}</Box>}
|
||||||
|
<Box grow="Yes">{children}</Box>
|
||||||
|
<DockedCallPanel />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue, useStore } from 'jotai';
|
||||||
import { useAtom, useSetAtom } from 'jotai';
|
|
||||||
import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
|
import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||||
@@ -23,13 +22,12 @@ import {
|
|||||||
guessPerfectParent,
|
guessPerfectParent,
|
||||||
} from '../../utils/room';
|
} from '../../utils/room';
|
||||||
import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
|
import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
|
||||||
import { getMxIdLocalPart, mxcUrlToHttp, getCanonicalAliasOrRoomId } from '../../utils/matrix';
|
import { getMxIdLocalPart, mxcUrlToHttp, getCanonicalAliasOrRoomId, encryptFile } from '../../utils/matrix';
|
||||||
import { mDirectAtom } from '../../state/mDirectList';
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||||
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { plainToEditorInput, toPlainText } from '../../components/editor';
|
|
||||||
import { ShareRoomPicker } from '../../features/ShareRoomPicker';
|
import { ShareRoomPicker } from '../../features/ShareRoomPicker';
|
||||||
import {
|
import {
|
||||||
isTauri,
|
isTauri,
|
||||||
@@ -46,10 +44,10 @@ import {
|
|||||||
} from '../../utils/backgroundSync';
|
} from '../../utils/backgroundSync';
|
||||||
import {
|
import {
|
||||||
TUploadItem,
|
TUploadItem,
|
||||||
roomIdToMsgDraftAtomFamily,
|
|
||||||
roomIdToUploadItemsAtomFamily,
|
roomIdToUploadItemsAtomFamily,
|
||||||
} from '../../state/room/roomInputDrafts';
|
} from '../../state/room/roomInputDrafts';
|
||||||
import { encryptFile } from '../../utils/matrix';
|
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
||||||
|
import { safeFile } from '../../utils/mimeTypes';
|
||||||
import {
|
import {
|
||||||
AndroidSharePayload,
|
AndroidSharePayload,
|
||||||
clearPendingAndroidShare,
|
clearPendingAndroidShare,
|
||||||
@@ -517,10 +515,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 +602,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 +653,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 +677,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 +715,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 +724,7 @@ function AndroidShareIntentHandler() {
|
|||||||
|
|
||||||
listenForAndroidShares((share) => {
|
listenForAndroidShares((share) => {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setPendingShare(share);
|
ingestShare(share);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then((handle) => {
|
.then((handle) => {
|
||||||
@@ -663,7 +738,7 @@ function AndroidShareIntentHandler() {
|
|||||||
mounted = false;
|
mounted = false;
|
||||||
void listenerHandle?.remove();
|
void listenerHandle?.remove();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [ingestShare]);
|
||||||
|
|
||||||
if (!pendingShare) return null;
|
if (!pendingShare) return null;
|
||||||
|
|
||||||
|
|||||||
81
overlay/src/app/utils/viewTransitions.ts
Normal file
81
overlay/src/app/utils/viewTransitions.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { isCapacitorNative } from './tauri';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View Transitions can leave an invisible hit-blocking overlay on Android
|
||||||
|
* WebView after DM ↔ space navigations. Keep them off for native/touch shells.
|
||||||
|
*/
|
||||||
|
const shouldDisableViewTransitions = (): boolean => {
|
||||||
|
if (typeof window === 'undefined') return true;
|
||||||
|
if (isCapacitorNative()) return true;
|
||||||
|
if (window.matchMedia?.('(pointer: coarse)').matches) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const supportsViewTransitions = (): boolean => {
|
||||||
|
if (shouldDisableViewTransitions()) return false;
|
||||||
|
return 'startViewTransition' in document;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const withViewTransition = async (callback: () => void | Promise<void>): Promise<void> => {
|
||||||
|
if (!supportsViewTransitions()) {
|
||||||
|
await callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = document as Document & {
|
||||||
|
startViewTransition: (callback: () => void | Promise<void>) => {
|
||||||
|
finished: Promise<void>;
|
||||||
|
updateCallbackDone: Promise<void>;
|
||||||
|
ready: Promise<void>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transition = doc.startViewTransition(async () => {
|
||||||
|
await callback();
|
||||||
|
});
|
||||||
|
|
||||||
|
await transition.finished;
|
||||||
|
} catch {
|
||||||
|
await callback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global link interception for View Transitions. No-op on Capacitor / touch.
|
||||||
|
* Also fights hash-router navigations — leave disabled on mobile.
|
||||||
|
*/
|
||||||
|
export const enableViewTransitionsForNavigation = (): void => {
|
||||||
|
if (!supportsViewTransitions()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLinkClick = (event: MouseEvent) => {
|
||||||
|
const target = (event.target as HTMLElement).closest('a');
|
||||||
|
|
||||||
|
if (!target) return;
|
||||||
|
if (target.target === '_blank') return;
|
||||||
|
if (target.origin !== window.location.origin) return;
|
||||||
|
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
|
||||||
|
|
||||||
|
const url = new URL(target.href);
|
||||||
|
|
||||||
|
if (url.origin === window.location.origin) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
withViewTransition(() => {
|
||||||
|
window.history.pushState({}, '', target.href);
|
||||||
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePopState = () => {
|
||||||
|
withViewTransition(() => {
|
||||||
|
// URL already changed
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('click', handleLinkClick);
|
||||||
|
window.addEventListener('popstate', handlePopState);
|
||||||
|
};
|
||||||
@@ -97,6 +97,11 @@ body {
|
|||||||
font-variant-ligatures: no-contextual;
|
font-variant-ligatures: no-contextual;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Landscape + keyboard: reclaim status-bar inset while chrome is hidden. */
|
||||||
|
html.immersive-landscape-keyboard {
|
||||||
|
--safe-area-inset-top: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Prevent zalgo text and combining diacritics from overflowing line boundaries */
|
/* Prevent zalgo text and combining diacritics from overflowing line boundaries */
|
||||||
p, span, div, a, button, li, td, th, h1, h2, h3, h4, h5, h6 {
|
p, span, div, a, button, li, td, th, h1, h2, h3, h4, h5, h6 {
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -521,3 +526,53 @@ html.android-capacitor body {
|
|||||||
word-spacing: normal;
|
word-spacing: normal;
|
||||||
font-variant-ligatures: normal;
|
font-variant-ligatures: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile: hide all scrollbars (touch scrolling still works) */
|
||||||
|
@media (max-width: 750px) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar,
|
||||||
|
*::-webkit-scrollbar-thumb,
|
||||||
|
*::-webkit-scrollbar-track,
|
||||||
|
*::-webkit-scrollbar-corner {
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twilight-theme ::-webkit-scrollbar,
|
||||||
|
.twilight-theme ::-webkit-scrollbar-thumb,
|
||||||
|
.twilight-theme ::-webkit-scrollbar-track,
|
||||||
|
.mocha-theme ::-webkit-scrollbar,
|
||||||
|
.mocha-theme ::-webkit-scrollbar-thumb,
|
||||||
|
.mocha-theme ::-webkit-scrollbar-track {
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html.android-capacitor,
|
||||||
|
html.android-capacitor *,
|
||||||
|
html.android-capacitor *::before,
|
||||||
|
html.android-capacitor *::after {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.android-capacitor *::-webkit-scrollbar,
|
||||||
|
html.android-capacitor *::-webkit-scrollbar-thumb,
|
||||||
|
html.android-capacitor *::-webkit-scrollbar-track,
|
||||||
|
html.android-capacitor *::-webkit-scrollbar-corner {
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|||||||
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.117",
|
"version": "4.11.125",
|
||||||
"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