Better Drag
All checks were successful
Build and Release / build-and-release (push) Successful in 14m0s
All checks were successful
Build and Release / build-and-release (push) Successful in 14m0s
This commit is contained in:
@@ -44,7 +44,8 @@
|
|||||||
"Bash(echo:*)",
|
"Bash(echo:*)",
|
||||||
"Bash(python -c \"import base64; print\\(base64.b64encode\\(open\\(r''C:\\\\Users\\\\bryan\\\\Desktop\\\\Discord Clone\\\\discord-clone-release.keystore'',''rb''\\).read\\(\\)\\).decode\\(\\)\\)\")",
|
"Bash(python -c \"import base64; print\\(base64.b64encode\\(open\\(r''C:\\\\Users\\\\bryan\\\\Desktop\\\\Discord Clone\\\\discord-clone-release.keystore'',''rb''\\).read\\(\\)\\).decode\\(\\)\\)\")",
|
||||||
"WebFetch(domain:gitea.moyettes.com)",
|
"WebFetch(domain:gitea.moyettes.com)",
|
||||||
"Bash(grep:*)"
|
"Bash(grep:*)",
|
||||||
|
"WebFetch(domain:addyosmani.com)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ android {
|
|||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 27
|
versionCode 27
|
||||||
versionName "1.0.36"
|
versionName "1.0.37"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
include ':capacitor-android'
|
include ':capacitor-android'
|
||||||
project(':capacitor-android').projectDir = new File('../../../node_modules/@capacitor/android/capacitor')
|
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||||
|
|
||||||
include ':capacitor-app'
|
include ':capacitor-app'
|
||||||
project(':capacitor-app').projectDir = new File('../../../node_modules/@capacitor/app/android')
|
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/electron",
|
"name": "@discord-clone/electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.36",
|
"version": "1.0.37",
|
||||||
"description": "Brycord - Electron app",
|
"description": "Brycord - Electron app",
|
||||||
"author": "Moyettes",
|
"author": "Moyettes",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/web",
|
"name": "@discord-clone/web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.36",
|
"version": "1.0.37",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
27
package-lock.json
generated
27
package-lock.json
generated
@@ -17,7 +17,7 @@
|
|||||||
},
|
},
|
||||||
"apps/android": {
|
"apps/android": {
|
||||||
"name": "@discord-clone/android",
|
"name": "@discord-clone/android",
|
||||||
"version": "1.0.34",
|
"version": "1.0.36",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "^6.0.0",
|
"@capacitor/android": "^6.0.0",
|
||||||
"@capacitor/app": "^6.0.0",
|
"@capacitor/app": "^6.0.0",
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"apps/electron": {
|
"apps/electron": {
|
||||||
"name": "@discord-clone/electron",
|
"name": "@discord-clone/electron",
|
||||||
"version": "1.0.34",
|
"version": "1.0.36",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discord-clone/shared": "*",
|
"@discord-clone/shared": "*",
|
||||||
"electron-log": "^5.4.3",
|
"electron-log": "^5.4.3",
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
},
|
},
|
||||||
"apps/web": {
|
"apps/web": {
|
||||||
"name": "@discord-clone/web",
|
"name": "@discord-clone/web",
|
||||||
"version": "1.0.34",
|
"version": "1.0.36",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discord-clone/platform-web": "*",
|
"@discord-clone/platform-web": "*",
|
||||||
"@discord-clone/shared": "*"
|
"@discord-clone/shared": "*"
|
||||||
@@ -3804,6 +3804,24 @@
|
|||||||
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
|
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/@use-gesture/core": {
|
||||||
|
"version": "10.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
|
||||||
|
"integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@use-gesture/react": {
|
||||||
|
"version": "10.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz",
|
||||||
|
"integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@use-gesture/core": "10.3.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">= 16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "5.1.4",
|
"version": "5.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz",
|
||||||
@@ -13652,7 +13670,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shared": {
|
"packages/shared": {
|
||||||
"name": "@discord-clone/shared",
|
"name": "@discord-clone/shared",
|
||||||
"version": "1.0.34",
|
"version": "1.0.36",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@convex-dev/presence": "^0.3.0",
|
"@convex-dev/presence": "^0.3.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -13660,6 +13678,7 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@livekit/components-react": "^2.9.17",
|
"@livekit/components-react": "^2.9.17",
|
||||||
"@livekit/components-styles": "^1.2.0",
|
"@livekit/components-styles": "^1.2.0",
|
||||||
|
"@use-gesture/react": "^10.3.1",
|
||||||
"convex": "^1.31.2",
|
"convex": "^1.31.2",
|
||||||
"livekit-client": "^2.16.1",
|
"livekit-client": "^2.16.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/shared",
|
"name": "@discord-clone/shared",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.36",
|
"version": "1.0.37",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/App.jsx",
|
"main": "src/App.jsx",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@livekit/components-react": "^2.9.17",
|
"@livekit/components-react": "^2.9.17",
|
||||||
"@livekit/components-styles": "^1.2.0",
|
"@livekit/components-styles": "^1.2.0",
|
||||||
|
"@use-gesture/react": "^10.3.1",
|
||||||
"convex": "^1.31.2",
|
"convex": "^1.31.2",
|
||||||
"livekit-client": "^2.16.1",
|
"livekit-client": "^2.16.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -35,7 +35,7 @@ import { useSearch } from '../contexts/SearchContext';
|
|||||||
import { useIsMobile } from '../hooks/useIsMobile';
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
import { generateUniqueMessage } from '../utils/floodMessages';
|
import { generateUniqueMessage } from '../utils/floodMessages';
|
||||||
|
|
||||||
const SCROLL_DEBUG = true;
|
const SCROLL_DEBUG = import.meta.env.DEV;
|
||||||
const scrollLog = (...args) => { if (SCROLL_DEBUG) console.log(...args); };
|
const scrollLog = (...args) => { if (SCROLL_DEBUG) console.log(...args); };
|
||||||
|
|
||||||
const metadataCache = new Map();
|
const metadataCache = new Map();
|
||||||
@@ -91,13 +91,13 @@ const DirectVideo = ({ src, marginTop = 8 }) => {
|
|||||||
if (ref.current) ref.current.play();
|
if (ref.current) ref.current.play();
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop, position: 'relative', display: 'inline-block', maxWidth: '100%' }}>
|
<div style={{ marginTop, position: 'relative', display: 'inline-block', maxWidth: '100%', aspectRatio: '16 / 9', minHeight: '169px' }}>
|
||||||
<video
|
<video
|
||||||
ref={ref}
|
ref={ref}
|
||||||
src={src}
|
src={src}
|
||||||
controls={showControls}
|
controls={showControls}
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
style={{ maxWidth: '100%', maxHeight: '300px', borderRadius: '8px', backgroundColor: 'black', display: 'block' }}
|
style={{ maxWidth: '100%', maxHeight: '300px', borderRadius: '8px', backgroundColor: 'black', display: 'block', width: '100%', height: '100%', objectFit: 'contain' }}
|
||||||
/>
|
/>
|
||||||
{!showControls && (
|
{!showControls && (
|
||||||
<div className="play-icon" onClick={handlePlay} style={{ cursor: 'pointer' }}>
|
<div className="play-icon" onClick={handlePlay} style={{ cursor: 'pointer' }}>
|
||||||
@@ -212,7 +212,18 @@ export const LinkPreview = ({ url }) => {
|
|||||||
return <DirectVideo src={url} />;
|
return <DirectVideo src={url} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading || !metadata || (!metadata.title && !metadata.image && !metadata.video)) return null;
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="link-preview-skeleton" style={{ marginTop: 8, height: 80, borderRadius: '4px' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', padding: '12px 16px' }}>
|
||||||
|
<div className="skeleton-bar" style={{ width: '40%', height: '10px' }} />
|
||||||
|
<div className="skeleton-bar" style={{ width: '70%', height: '12px' }} />
|
||||||
|
<div className="skeleton-bar" style={{ width: '90%', height: '10px' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!metadata || (!metadata.title && !metadata.image && !metadata.video)) return null;
|
||||||
|
|
||||||
if (metadata.video && !isYouTube) {
|
if (metadata.video && !isYouTube) {
|
||||||
const handlePlayClick = () => {
|
const handlePlayClick = () => {
|
||||||
@@ -221,12 +232,12 @@ export const LinkPreview = ({ url }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="preview-video-standalone" style={{ marginTop: 8, position: 'relative', display: 'inline-block', maxWidth: '100%' }}>
|
<div className="preview-video-standalone" style={{ marginTop: 8, position: 'relative', display: 'inline-block', maxWidth: '100%', aspectRatio: '16 / 9', minHeight: '169px' }}>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={metadata.video}
|
src={metadata.video}
|
||||||
controls={showControls}
|
controls={showControls}
|
||||||
style={{ maxWidth: '100%', maxHeight: '300px', borderRadius: '4px', backgroundColor: 'black', display: 'block' }}
|
style={{ maxWidth: '100%', maxHeight: '300px', borderRadius: '4px', backgroundColor: 'black', display: 'block', width: '100%', height: '100%', objectFit: 'contain' }}
|
||||||
/>
|
/>
|
||||||
{!showControls && (
|
{!showControls && (
|
||||||
<div className="play-icon" onClick={handlePlayClick} style={{ cursor: 'pointer' }}>
|
<div className="play-icon" onClick={handlePlayClick} style={{ cursor: 'pointer' }}>
|
||||||
@@ -239,7 +250,7 @@ export const LinkPreview = ({ url }) => {
|
|||||||
|
|
||||||
if (metadata.description === 'Image File' && metadata.image) {
|
if (metadata.description === 'Image File' && metadata.image) {
|
||||||
return (
|
return (
|
||||||
<div className="preview-image-standalone" style={{ marginTop: 8, display: 'inline-block', maxWidth: '100%', cursor: 'pointer' }}>
|
<div className="preview-image-standalone" style={{ marginTop: 8, display: 'inline-block', maxWidth: '100%', cursor: 'pointer', minHeight: '200px' }}>
|
||||||
<img src={metadata.image} alt="Preview" draggable="false" style={{ maxWidth: '100%', maxHeight: '350px', borderRadius: '8px', display: 'block' }} />
|
<img src={metadata.image} alt="Preview" draggable="false" style={{ maxWidth: '100%', maxHeight: '350px', borderRadius: '8px', display: 'block' }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -339,17 +350,57 @@ const Attachment = ({ metadata, onLoad, onImageClick }) => {
|
|||||||
return () => { isMounted = false; };
|
return () => { isMounted = false; };
|
||||||
}, [metadata, onLoad]);
|
}, [metadata, onLoad]);
|
||||||
|
|
||||||
if (loading) return <div style={{ color: 'var(--header-secondary)', fontSize: '12px' }}>Downloading & Decrypting...</div>;
|
if (loading) {
|
||||||
|
// Skeleton placeholder sized to match final content — prevents layout shift
|
||||||
|
if (metadata.mimeType.startsWith('image/')) {
|
||||||
|
const skelW = metadata.width ? Math.min(metadata.width, 400) : 300;
|
||||||
|
const skelH = metadata.height && metadata.width
|
||||||
|
? Math.round(skelW * (metadata.height / metadata.width))
|
||||||
|
: 200;
|
||||||
|
return (
|
||||||
|
<div className="attachment-skeleton" style={{ width: skelW, height: skelH, maxWidth: '100%', borderRadius: '4px' }}>
|
||||||
|
<div className="loading-spinner" style={{ width: '20px', height: '20px', borderWidth: '2px' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (metadata.mimeType.startsWith('video/')) {
|
||||||
|
const skelW = metadata.width ? Math.min(metadata.width, 300) : 300;
|
||||||
|
const skelH = metadata.height && metadata.width
|
||||||
|
? Math.round(skelW * (metadata.height / metadata.width))
|
||||||
|
: 169;
|
||||||
|
return (
|
||||||
|
<div className="attachment-skeleton" style={{ width: skelW, height: skelH, maxWidth: '300px', borderRadius: '4px' }}>
|
||||||
|
<div className="loading-spinner" style={{ width: '20px', height: '20px', borderWidth: '2px' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// File attachment skeleton — matches final file card layout
|
||||||
|
return (
|
||||||
|
<div className="attachment-skeleton" style={{ width: 300, height: 52, maxWidth: '300px', borderRadius: '4px' }}>
|
||||||
|
<div className="loading-spinner" style={{ width: '16px', height: '16px', borderWidth: '2px' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (error) return <div style={{ color: '#ed4245', fontSize: '12px' }}>{error}</div>;
|
if (error) return <div style={{ color: '#ed4245', fontSize: '12px' }}>{error}</div>;
|
||||||
|
|
||||||
if (metadata.mimeType.startsWith('image/')) {
|
if (metadata.mimeType.startsWith('image/')) {
|
||||||
return <img src={url} alt={metadata.filename} draggable="false" style={{ maxWidth: '100%', maxHeight: '300px', borderRadius: '4px', cursor: 'zoom-in' }} onLoad={onLoad} onClick={() => onImageClick(url)} />;
|
const containerStyle = metadata.width && metadata.height
|
||||||
|
? { aspectRatio: `${metadata.width} / ${metadata.height}`, maxWidth: Math.min(metadata.width, 400), maxHeight: '300px' }
|
||||||
|
: { minHeight: '200px', maxHeight: '300px' };
|
||||||
|
return (
|
||||||
|
<div style={{ ...containerStyle, display: 'inline-block', maxWidth: '100%' }}>
|
||||||
|
<img src={url} alt={metadata.filename} draggable="false" style={{ maxWidth: '100%', maxHeight: '300px', borderRadius: '4px', cursor: 'zoom-in', display: 'block' }} onLoad={onLoad} onClick={() => onImageClick(url)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (metadata.mimeType.startsWith('video/')) {
|
if (metadata.mimeType.startsWith('video/')) {
|
||||||
const handlePlayClick = () => { setShowControls(true); if (videoRef.current) videoRef.current.play(); };
|
const handlePlayClick = () => { setShowControls(true); if (videoRef.current) videoRef.current.play(); };
|
||||||
|
const videoAspect = metadata.width && metadata.height
|
||||||
|
? `${metadata.width} / ${metadata.height}`
|
||||||
|
: '16 / 9';
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop: 8, position: 'relative', display: 'inline-block', maxWidth: '300px' }}>
|
<div style={{ marginTop: 8, position: 'relative', display: 'inline-block', maxWidth: '300px', aspectRatio: videoAspect, minHeight: '169px' }}>
|
||||||
<video ref={videoRef} src={url} controls={showControls} style={{ maxWidth: '300px', borderRadius: '4px', display: 'block', backgroundColor: 'black' }} onLoadedData={onLoad} />
|
<video ref={videoRef} src={url} controls={showControls} style={{ maxWidth: '300px', borderRadius: '4px', display: 'block', backgroundColor: 'black', width: '100%', height: '100%', objectFit: 'contain' }} onLoadedData={onLoad} />
|
||||||
{!showControls && <div className="play-icon" onClick={handlePlayClick} style={{ cursor: 'pointer' }}>▶</div>}
|
{!showControls && <div className="play-icon" onClick={handlePlayClick} style={{ cursor: 'pointer' }}>▶</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -722,10 +773,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||||
if (channelLoadIdRef.current === loadId) scrollEnd();
|
if (channelLoadIdRef.current === loadId) scrollEnd();
|
||||||
}));
|
}));
|
||||||
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 300);
|
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) { scrollEnd(); isInitialLoadRef.current = false; } }, 500);
|
||||||
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 800);
|
|
||||||
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 1200);
|
|
||||||
setTimeout(() => { if (channelLoadIdRef.current === loadId) isInitialLoadRef.current = false; }, 1500);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -842,7 +890,8 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
setDecryptedMessages(buildFromCache());
|
setDecryptedMessages(buildFromCache());
|
||||||
|
|
||||||
// After decryption, items may be taller — re-scroll to bottom.
|
// After decryption, items may be taller — re-scroll to bottom.
|
||||||
// Double-rAF waits for paint + ResizeObserver cycle; escalating timeouts are safety nets.
|
// Double-rAF waits for paint + ResizeObserver cycle; single safety timeout since
|
||||||
|
// dimension reservation on media prevents most late-sizing shifts.
|
||||||
if (isInitialLoadRef.current && !initialScrollScheduledRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
|
if (isInitialLoadRef.current && !initialScrollScheduledRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
|
||||||
initialScrollScheduledRef.current = true;
|
initialScrollScheduledRef.current = true;
|
||||||
const loadId = channelLoadIdRef.current;
|
const loadId = channelLoadIdRef.current;
|
||||||
@@ -851,10 +900,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||||
if (channelLoadIdRef.current === loadId) scrollEnd();
|
if (channelLoadIdRef.current === loadId) scrollEnd();
|
||||||
}));
|
}));
|
||||||
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 300);
|
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) { scrollEnd(); isInitialLoadRef.current = false; } }, 500);
|
||||||
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 800);
|
|
||||||
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 1200);
|
|
||||||
setTimeout(() => { if (channelLoadIdRef.current === loadId) isInitialLoadRef.current = false; }, 1500);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1310,9 +1356,8 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
if (el) el.scrollTop = el.scrollHeight;
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
};
|
};
|
||||||
snap();
|
snap();
|
||||||
// Escalating retries for late-sizing content (images, embeds)
|
// Single rAF retry — dimension reservation makes multi-retry unnecessary
|
||||||
setTimeout(snap, 50);
|
requestAnimationFrame(snap);
|
||||||
setTimeout(snap, 150);
|
|
||||||
} else if (virtuosoRef.current && !userIsScrolledUpRef.current) {
|
} else if (virtuosoRef.current && !userIsScrolledUpRef.current) {
|
||||||
virtuosoRef.current.scrollToIndex({
|
virtuosoRef.current.scrollToIndex({
|
||||||
index: 'LAST',
|
index: 'LAST',
|
||||||
@@ -1812,6 +1857,24 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
const handleDragLeave = (e) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget.contains(e.relatedTarget)) return; setIsDragging(false); };
|
const handleDragLeave = (e) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget.contains(e.relatedTarget)) return; setIsDragging(false); };
|
||||||
const handleDrop = (e) => { if (!isDragging) return; e.preventDefault(); e.stopPropagation(); setIsDragging(false); if (e.dataTransfer.files && e.dataTransfer.files.length > 0) Array.from(e.dataTransfer.files).forEach(processFile); };
|
const handleDrop = (e) => { if (!isDragging) return; e.preventDefault(); e.stopPropagation(); setIsDragging(false); if (e.dataTransfer.files && e.dataTransfer.files.length > 0) Array.from(e.dataTransfer.files).forEach(processFile); };
|
||||||
|
|
||||||
|
const getMediaDimensions = (file) => new Promise((resolve) => {
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
const img = new Image();
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
img.onload = () => { URL.revokeObjectURL(objectUrl); resolve({ width: img.naturalWidth, height: img.naturalHeight }); };
|
||||||
|
img.onerror = () => { URL.revokeObjectURL(objectUrl); resolve(null); };
|
||||||
|
img.src = objectUrl;
|
||||||
|
} else if (file.type.startsWith('video/')) {
|
||||||
|
const video = document.createElement('video');
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
video.onloadedmetadata = () => { URL.revokeObjectURL(objectUrl); resolve({ width: video.videoWidth, height: video.videoHeight }); };
|
||||||
|
video.onerror = () => { URL.revokeObjectURL(objectUrl); resolve(null); };
|
||||||
|
video.src = objectUrl;
|
||||||
|
} else {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const uploadAndSendFile = async (file) => {
|
const uploadAndSendFile = async (file) => {
|
||||||
const fileKey = await crypto.randomBytes(32);
|
const fileKey = await crypto.randomBytes(32);
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
@@ -1827,6 +1890,8 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
|
|
||||||
const fileUrl = await convex.mutation(api.files.validateUpload, { storageId });
|
const fileUrl = await convex.mutation(api.files.validateUpload, { storageId });
|
||||||
|
|
||||||
|
const dims = await getMediaDimensions(file);
|
||||||
|
|
||||||
const metadata = {
|
const metadata = {
|
||||||
type: 'attachment',
|
type: 'attachment',
|
||||||
url: fileUrl,
|
url: fileUrl,
|
||||||
@@ -1834,7 +1899,8 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
key: fileKey,
|
key: fileKey,
|
||||||
iv: encrypted.iv
|
iv: encrypted.iv,
|
||||||
|
...(dims && { width: dims.width, height: dims.height })
|
||||||
};
|
};
|
||||||
|
|
||||||
await sendMessage(JSON.stringify(metadata));
|
await sendMessage(JSON.stringify(metadata));
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useVoice } from '../contexts/VoiceContext';
|
|||||||
import { CrownIcon, SharingIcon } from '../assets/icons';
|
import { CrownIcon, SharingIcon } from '../assets/icons';
|
||||||
import ColoredIcon from './ColoredIcon';
|
import ColoredIcon from './ColoredIcon';
|
||||||
import ChangeNicknameModal from './ChangeNicknameModal';
|
import ChangeNicknameModal from './ChangeNicknameModal';
|
||||||
|
import avatarDecoStatic from '../assets/avatar_decorations/a_dcfe10bac4a782ffb5eefef7a8003115.png';
|
||||||
|
import avatarDecoAnimated from '../assets/avatar_decorations/passthrough/a_dcfe10bac4a782ffb5eefef7a8003115.png';
|
||||||
|
|
||||||
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||||
|
|
||||||
@@ -161,6 +163,8 @@ const MembersList = ({ channelId, visible, onMemberClick, userId, myPermissions,
|
|||||||
{(member.displayName || member.username).substring(0, 1).toUpperCase()}
|
{(member.displayName || member.username).substring(0, 1).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* <img className="avatar-decoration avatar-decoration-static" src={avatarDecoStatic} alt="" aria-hidden="true" /> */}
|
||||||
|
{/* <img className="avatar-decoration avatar-decoration-animated" src={avatarDecoAnimated} alt="" aria-hidden="true" /> */}
|
||||||
<div
|
<div
|
||||||
className="member-status-dot"
|
className="member-status-dot"
|
||||||
style={{ backgroundColor: STATUS_COLORS[effectiveStatus] || STATUS_COLORS.offline }}
|
style={{ backgroundColor: STATUS_COLORS[effectiveStatus] || STATUS_COLORS.offline }}
|
||||||
@@ -196,6 +200,7 @@ const MembersList = ({ channelId, visible, onMemberClick, userId, myPermissions,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="members-list">
|
<div className="members-list">
|
||||||
|
{/* <img src={avatarDecoAnimated} alt="" style={{ display: 'none' }} aria-hidden="true" /> */}
|
||||||
{sortedGroups.map(group => (
|
{sortedGroups.map(group => (
|
||||||
<React.Fragment key={group.role.name}>
|
<React.Fragment key={group.role.name}>
|
||||||
<div className="members-role-header">
|
<div className="members-role-header">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { useDrag } from '@use-gesture/react';
|
||||||
|
|
||||||
const EDGE_THRESHOLD = 20;
|
const EDGE_THRESHOLD = 200;
|
||||||
const SNAP_THRESHOLD = 0.4;
|
const SNAP_THRESHOLD = 0.4;
|
||||||
const VELOCITY_THRESHOLD = 0.5;
|
const VELOCITY_THRESHOLD = 0.5;
|
||||||
|
|
||||||
@@ -8,7 +9,6 @@ export function useSwipeNavigation({ enabled, canSwipeToChat }) {
|
|||||||
const [activeView, setActiveView] = useState('sidebar');
|
const [activeView, setActiveView] = useState('sidebar');
|
||||||
const trayRef = useRef(null);
|
const trayRef = useRef(null);
|
||||||
|
|
||||||
// Refs so touch handlers always read current values
|
|
||||||
const activeViewRef = useRef(activeView);
|
const activeViewRef = useRef(activeView);
|
||||||
const canSwipeToChatRef = useRef(canSwipeToChat);
|
const canSwipeToChatRef = useRef(canSwipeToChat);
|
||||||
const screenWidthRef = useRef(window.innerWidth);
|
const screenWidthRef = useRef(window.innerWidth);
|
||||||
@@ -16,10 +16,8 @@ export function useSwipeNavigation({ enabled, canSwipeToChat }) {
|
|||||||
useEffect(() => { activeViewRef.current = activeView; }, [activeView]);
|
useEffect(() => { activeViewRef.current = activeView; }, [activeView]);
|
||||||
useEffect(() => { canSwipeToChatRef.current = canSwipeToChat; }, [canSwipeToChat]);
|
useEffect(() => { canSwipeToChatRef.current = canSwipeToChat; }, [canSwipeToChat]);
|
||||||
|
|
||||||
// Track swiping state for pointer-events disabling
|
|
||||||
const [isSwiping, setIsSwiping] = useState(false);
|
const [isSwiping, setIsSwiping] = useState(false);
|
||||||
|
|
||||||
// Programmatic navigation with smooth transition
|
|
||||||
const goToChat = useCallback(() => {
|
const goToChat = useCallback(() => {
|
||||||
const tray = trayRef.current;
|
const tray = trayRef.current;
|
||||||
if (tray) {
|
if (tray) {
|
||||||
@@ -38,6 +36,7 @@ export function useSwipeNavigation({ enabled, canSwipeToChat }) {
|
|||||||
setActiveView('sidebar');
|
setActiveView('sidebar');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Keep screen width in sync
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
|
|
||||||
@@ -50,178 +49,96 @@ export function useSwipeNavigation({ enabled, canSwipeToChat }) {
|
|||||||
return () => window.removeEventListener('resize', handleResize);
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
}, [enabled]);
|
}, [enabled]);
|
||||||
|
|
||||||
|
// Measure panel width on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
const tray = trayRef.current;
|
const tray = trayRef.current;
|
||||||
if (!tray) return;
|
if (!tray) return;
|
||||||
|
|
||||||
// Measure actual rendered panel width (CSS 100vw) instead of window.innerWidth
|
|
||||||
const panel = tray.querySelector('.mobile-swipe-panel');
|
const panel = tray.querySelector('.mobile-swipe-panel');
|
||||||
if (panel) {
|
if (panel) {
|
||||||
screenWidthRef.current = panel.offsetWidth;
|
screenWidthRef.current = panel.offsetWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
let tracking = false;
|
|
||||||
let decided = false; // whether we've decided horizontal vs vertical
|
|
||||||
let startX = 0;
|
|
||||||
let startY = 0;
|
|
||||||
let startTime = 0;
|
|
||||||
let baseOffset = 0; // starting translateX when touch began
|
|
||||||
let lastX = 0;
|
|
||||||
let lastTime = 0;
|
|
||||||
|
|
||||||
function getCurrentTranslateX() {
|
|
||||||
const style = window.getComputedStyle(tray);
|
|
||||||
const matrix = style.transform;
|
|
||||||
if (!matrix || matrix === 'none') return 0;
|
|
||||||
// matrix(a, b, c, d, tx, ty)
|
|
||||||
const match = matrix.match(/matrix\(([^)]+)\)/);
|
|
||||||
if (match) {
|
|
||||||
const values = match[1].split(',').map(Number);
|
|
||||||
return values[4] || 0;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTouchStart(e) {
|
|
||||||
if (e.touches.length !== 1) return;
|
|
||||||
const touch = e.touches[0];
|
|
||||||
const sw = screenWidthRef.current;
|
|
||||||
const view = activeViewRef.current;
|
|
||||||
|
|
||||||
// Edge detection: only start from edges
|
|
||||||
const fromLeftEdge = touch.clientX <= EDGE_THRESHOLD;
|
|
||||||
const fromRightEdge = touch.clientX >= sw - EDGE_THRESHOLD;
|
|
||||||
|
|
||||||
if (view === 'chat' && fromLeftEdge) {
|
|
||||||
// Potential swipe to sidebar
|
|
||||||
} else if (view === 'sidebar' && fromRightEdge && canSwipeToChatRef.current) {
|
|
||||||
// Potential swipe to chat
|
|
||||||
} else {
|
|
||||||
return; // Not an edge touch
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture current visual position (handles mid-animation interruption)
|
|
||||||
tray.style.transition = 'none';
|
|
||||||
baseOffset = getCurrentTranslateX();
|
|
||||||
tray.style.transform = `translateX(${baseOffset}px)`;
|
|
||||||
|
|
||||||
startX = touch.clientX;
|
|
||||||
startY = touch.clientY;
|
|
||||||
startTime = Date.now();
|
|
||||||
lastX = touch.clientX;
|
|
||||||
lastTime = startTime;
|
|
||||||
tracking = false;
|
|
||||||
decided = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTouchMove(e) {
|
|
||||||
if (e.touches.length !== 1) return;
|
|
||||||
if (decided && !tracking) return; // Decided vertical, ignore
|
|
||||||
|
|
||||||
const touch = e.touches[0];
|
|
||||||
const deltaX = touch.clientX - startX;
|
|
||||||
const deltaY = touch.clientY - startY;
|
|
||||||
|
|
||||||
// If we haven't decided intent yet
|
|
||||||
if (!decided) {
|
|
||||||
const absDx = Math.abs(deltaX);
|
|
||||||
const absDy = Math.abs(deltaY);
|
|
||||||
// Need some movement to decide
|
|
||||||
if (absDx < 12 && absDy < 12) return;
|
|
||||||
|
|
||||||
decided = true;
|
|
||||||
if (absDx > absDy * 2) {
|
|
||||||
tracking = true;
|
|
||||||
setIsSwiping(true);
|
|
||||||
} else {
|
|
||||||
// Vertical — let it scroll
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tracking) return;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const sw = screenWidthRef.current;
|
|
||||||
const view = activeViewRef.current;
|
|
||||||
let newOffset = baseOffset + deltaX;
|
|
||||||
|
|
||||||
// Clamp: tray can only be between -sw (chat) and 0 (sidebar)
|
|
||||||
if (view === 'chat') {
|
|
||||||
// On chat, base is -sw. Allow swiping right (toward 0) but not past
|
|
||||||
newOffset = Math.max(-sw, Math.min(0, newOffset));
|
|
||||||
} else {
|
|
||||||
// On sidebar, base is 0. Allow swiping left (toward -sw) but not past
|
|
||||||
newOffset = Math.max(-sw, Math.min(0, newOffset));
|
|
||||||
}
|
|
||||||
|
|
||||||
tray.style.transform = `translateX(${newOffset}px)`;
|
|
||||||
lastX = touch.clientX;
|
|
||||||
lastTime = Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTouchEnd(e) {
|
|
||||||
if (!tracking) {
|
|
||||||
decided = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tracking = false;
|
|
||||||
decided = false;
|
|
||||||
setIsSwiping(false);
|
|
||||||
|
|
||||||
const sw = screenWidthRef.current;
|
|
||||||
const view = activeViewRef.current;
|
|
||||||
const currentOffset = getCurrentTranslateX();
|
|
||||||
const elapsed = Date.now() - lastTime;
|
|
||||||
const velocity = elapsed > 0 ? Math.abs(lastX - startX) / (Date.now() - startTime) : 0;
|
|
||||||
|
|
||||||
let shouldTransition = false;
|
|
||||||
|
|
||||||
if (view === 'chat') {
|
|
||||||
// Swiping right to sidebar: progress is how far from -sw toward 0
|
|
||||||
const progress = (currentOffset - (-sw)) / sw;
|
|
||||||
shouldTransition = progress > SNAP_THRESHOLD || velocity > VELOCITY_THRESHOLD;
|
|
||||||
} else {
|
|
||||||
// Swiping left to chat: progress is how far from 0 toward -sw
|
|
||||||
const progress = Math.abs(currentOffset) / sw;
|
|
||||||
shouldTransition = progress > SNAP_THRESHOLD || velocity > VELOCITY_THRESHOLD;
|
|
||||||
}
|
|
||||||
|
|
||||||
tray.style.transition = 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
|
|
||||||
|
|
||||||
if (shouldTransition) {
|
|
||||||
if (view === 'chat') {
|
|
||||||
tray.style.transform = 'translateX(0px)';
|
|
||||||
setActiveView('sidebar');
|
|
||||||
} else {
|
|
||||||
tray.style.transform = `translateX(-${sw}px)`;
|
|
||||||
setActiveView('chat');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Snap back
|
|
||||||
if (view === 'chat') {
|
|
||||||
tray.style.transform = `translateX(-${sw}px)`;
|
|
||||||
} else {
|
|
||||||
tray.style.transform = 'translateX(0px)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tray.addEventListener('touchstart', onTouchStart, { passive: true });
|
|
||||||
tray.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
||||||
tray.addEventListener('touchend', onTouchEnd, { passive: true });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
tray.removeEventListener('touchstart', onTouchStart);
|
|
||||||
tray.removeEventListener('touchmove', onTouchMove);
|
|
||||||
tray.removeEventListener('touchend', onTouchEnd);
|
|
||||||
};
|
|
||||||
}, [enabled]);
|
}, [enabled]);
|
||||||
|
|
||||||
// Resting style: set initial position without transition on mount / view change
|
function getCurrentTranslateX(tray) {
|
||||||
|
const style = window.getComputedStyle(tray);
|
||||||
|
const matrix = style.transform;
|
||||||
|
if (!matrix || matrix === 'none') return 0;
|
||||||
|
const match = matrix.match(/matrix\(([^)]+)\)/);
|
||||||
|
if (match) {
|
||||||
|
const values = match[1].split(',').map(Number);
|
||||||
|
return values[4] || 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bind = useDrag(
|
||||||
|
({ active, movement: [mx], velocity: [vx], direction: [dx], cancel, tap, first, event }) => {
|
||||||
|
if (!enabled) return;
|
||||||
|
if (tap) return;
|
||||||
|
|
||||||
|
const tray = trayRef.current;
|
||||||
|
if (!tray) return;
|
||||||
|
const sw = screenWidthRef.current;
|
||||||
|
const view = activeViewRef.current;
|
||||||
|
|
||||||
|
if (first) {
|
||||||
|
const touchX = event.touches?.[0]?.clientX ?? event.clientX;
|
||||||
|
const fromLeftEdge = touchX <= EDGE_THRESHOLD;
|
||||||
|
const fromRightEdge = touchX >= sw - EDGE_THRESHOLD;
|
||||||
|
|
||||||
|
if (view === 'chat' && !fromLeftEdge) { cancel(); return; }
|
||||||
|
if (view === 'sidebar' && (!fromRightEdge || !canSwipeToChatRef.current)) { cancel(); return; }
|
||||||
|
|
||||||
|
// Freeze current position for mid-animation interruption
|
||||||
|
tray.style.transition = 'none';
|
||||||
|
const currentX = getCurrentTranslateX(tray);
|
||||||
|
tray.style.transform = `translateX(${currentX}px)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
setIsSwiping(true);
|
||||||
|
const base = view === 'chat' ? -sw : 0;
|
||||||
|
const newOffset = Math.max(-sw, Math.min(0, base + mx));
|
||||||
|
tray.style.transition = 'none';
|
||||||
|
tray.style.transform = `translateX(${newOffset}px)`;
|
||||||
|
} else {
|
||||||
|
// Release — decide snap target
|
||||||
|
setIsSwiping(false);
|
||||||
|
const currentOffset = getCurrentTranslateX(tray);
|
||||||
|
|
||||||
|
let shouldTransition = false;
|
||||||
|
if (view === 'chat') {
|
||||||
|
const progress = (currentOffset - (-sw)) / sw;
|
||||||
|
shouldTransition = progress > SNAP_THRESHOLD || vx > VELOCITY_THRESHOLD;
|
||||||
|
} else {
|
||||||
|
const progress = Math.abs(currentOffset) / sw;
|
||||||
|
shouldTransition = progress > SNAP_THRESHOLD || vx > VELOCITY_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
tray.style.transition = 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||||
|
|
||||||
|
if (shouldTransition) {
|
||||||
|
if (view === 'chat') {
|
||||||
|
tray.style.transform = 'translateX(0px)';
|
||||||
|
setActiveView('sidebar');
|
||||||
|
} else {
|
||||||
|
tray.style.transform = `translateX(-${sw}px)`;
|
||||||
|
setActiveView('chat');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Snap back
|
||||||
|
if (view === 'chat') {
|
||||||
|
tray.style.transform = `translateX(-${sw}px)`;
|
||||||
|
} else {
|
||||||
|
tray.style.transform = 'translateX(0px)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ axis: 'x', filterTaps: true, pointer: { touch: true } }
|
||||||
|
);
|
||||||
|
|
||||||
const trayStyle = useMemo(() => {
|
const trayStyle = useMemo(() => {
|
||||||
if (!enabled) return {};
|
if (!enabled) return {};
|
||||||
return {
|
return {
|
||||||
@@ -229,5 +146,7 @@ export function useSwipeNavigation({ enabled, canSwipeToChat }) {
|
|||||||
};
|
};
|
||||||
}, [enabled, activeView]);
|
}, [enabled, activeView]);
|
||||||
|
|
||||||
return { activeView, goToChat, goToSidebar, trayRef, trayStyle, isSwiping };
|
const swipeBindProps = enabled ? bind() : {};
|
||||||
|
|
||||||
|
return { activeView, goToChat, goToSidebar, trayRef, trayStyle, isSwiping, swipeBindProps };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -556,6 +556,9 @@ body {
|
|||||||
/* Virtuoso scroller scrollbar styles */
|
/* Virtuoso scroller scrollbar styles */
|
||||||
.messages-list [data-virtuoso-scroller] {
|
.messages-list [data-virtuoso-scroller] {
|
||||||
overflow-y: auto !important;
|
overflow-y: auto !important;
|
||||||
|
overflow-anchor: none;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
will-change: scroll-position;
|
||||||
}
|
}
|
||||||
.messages-list [data-virtuoso-scroller]::-webkit-scrollbar {
|
.messages-list [data-virtuoso-scroller]::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
@@ -566,7 +569,32 @@ body {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ... existing styles ... */
|
/* Attachment & link preview loading skeletons */
|
||||||
|
.attachment-skeleton {
|
||||||
|
background-color: var(--embed-background, #2f3136);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-preview-skeleton {
|
||||||
|
background-color: var(--embed-background, #2f3136);
|
||||||
|
border-left: 4px solid #202225;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-bar {
|
||||||
|
background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
.preview-description {
|
.preview-description {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -1055,6 +1083,8 @@ body {
|
|||||||
position: relative;
|
position: relative;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-avatar {
|
.member-avatar {
|
||||||
@@ -1069,6 +1099,23 @@ body {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar-decoration {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-decoration-static { display: block; }
|
||||||
|
.avatar-decoration-animated { display: none; }
|
||||||
|
|
||||||
|
.member-item:hover .avatar-decoration-static { display: none; }
|
||||||
|
.member-item:hover .avatar-decoration-animated { display: block; }
|
||||||
|
|
||||||
.member-status-dot {
|
.member-status-dot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -1px;
|
bottom: -1px;
|
||||||
@@ -1077,6 +1124,7 @@ body {
|
|||||||
height: 10px;
|
height: 10px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 2px solid var(--bg-secondary);
|
border: 2px solid var(--bg-secondary);
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-info {
|
.member-info {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const Chat = () => {
|
|||||||
const [showPinned, setShowPinned] = useState(false);
|
const [showPinned, setShowPinned] = useState(false);
|
||||||
const [showMobileMembersScreen, setShowMobileMembersScreen] = useState(false);
|
const [showMobileMembersScreen, setShowMobileMembersScreen] = useState(false);
|
||||||
|
|
||||||
const { activeView: mobileView, goToChat, goToSidebar, trayRef, trayStyle, isSwiping } =
|
const { activeView: mobileView, goToChat, goToSidebar, trayRef, trayStyle, isSwiping, swipeBindProps } =
|
||||||
useSwipeNavigation({
|
useSwipeNavigation({
|
||||||
enabled: isMobile,
|
enabled: isMobile,
|
||||||
canSwipeToChat: activeChannel !== null || activeDMChannel !== null || view === 'me'
|
canSwipeToChat: activeChannel !== null || activeDMChannel !== null || view === 'me'
|
||||||
@@ -745,6 +745,7 @@ const Chat = () => {
|
|||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<div
|
<div
|
||||||
ref={trayRef}
|
ref={trayRef}
|
||||||
|
{...swipeBindProps}
|
||||||
className={`mobile-swipe-tray${isSwiping ? ' is-swiping' : ''}`}
|
className={`mobile-swipe-tray${isSwiping ? ' is-swiping' : ''}`}
|
||||||
style={trayStyle}
|
style={trayStyle}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user