feat(ui): motion that answers the finger, and honours reduced-motion; release v6.2.0
The mobile menu becomes a surface you drag rather than one that appears and vanishes: it tracks the finger, carries its velocity when flicked, can be caught mid-flight, and resists past the edge instead of stopping dead. Buttons react on press rather than on release, and the browser's tap delay is gone — nothing here zooms, so the wait bought nothing. prefers-reduced-motion, prefers-reduced-transparency and prefers-contrast are now respected instead of ignored: looping decoration stops, transitions fade rather than slide, the chat jumps to the newest message, frosted panels turn solid and edges get drawn. What stops is the movement, never the information — recording indicators and other status stay. Also bumps the desktop download buttons to 0.5.0. The version lives in one constant per source file and tests/desktop-download-links.test.mjs fetches every generated URL, so the links are proven to serve a real asset rather than merely looking well-formed. Claude-Session: https://claude.ai/code/session_01ARFZ9G6P1e1B4w75kFaeNw
This commit is contained in:
Vendored
+5
-3
@@ -21332,7 +21332,7 @@ var SecureMasterKeyManager = class {
|
||||
var import_NotificationIntegration = __toESM(require_NotificationIntegration());
|
||||
|
||||
// package.json
|
||||
var version = "6.1.1";
|
||||
var version = "6.2.0";
|
||||
|
||||
// src/components/ui/Header.jsx
|
||||
var APP_VERSION = `v${version}`;
|
||||
@@ -21750,8 +21750,10 @@ Right-click or Ctrl+click to disconnect`,
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
const GLASS = "blur(20px) saturate(180%)";
|
||||
const MATERIALISE = "background .25s ease, backdrop-filter .25s ease, -webkit-backdrop-filter .25s ease, border-color .25s ease";
|
||||
const overlay = { position: "fixed", top: 0, left: 0, right: 0 };
|
||||
const headerStyle = onLanding ? scrolled ? { ...overlay, background: "rgba(15,15,17,0.72)", backdropFilter: "blur(14px)", WebkitBackdropFilter: "blur(14px)", borderBottom: "1px solid rgba(255,255,255,0.06)", transition: "background .25s ease, backdrop-filter .25s ease, border-color .25s ease" } : { ...overlay, background: "transparent", backdropFilter: "none", WebkitBackdropFilter: "none", borderBottom: "1px solid transparent", transition: "background .25s ease, backdrop-filter .25s ease, border-color .25s ease" } : { background: "rgba(18,18,20,0.72)", backdropFilter: "blur(14px)", WebkitBackdropFilter: "blur(14px)", borderBottom: "1px solid rgba(255,255,255,0.06)" };
|
||||
const headerStyle = onLanding ? scrolled ? { ...overlay, background: "rgba(15,15,17,0.72)", backdropFilter: GLASS, WebkitBackdropFilter: GLASS, borderBottom: "1px solid rgba(255,255,255,0.06)", transition: MATERIALISE } : { ...overlay, background: "transparent", backdropFilter: "blur(0px) saturate(100%)", WebkitBackdropFilter: "blur(0px) saturate(100%)", borderBottom: "1px solid transparent", transition: MATERIALISE } : { background: "rgba(18,18,20,0.72)", backdropFilter: GLASS, WebkitBackdropFilter: GLASS, borderBottom: "1px solid rgba(255,255,255,0.06)" };
|
||||
return React.createElement("header", {
|
||||
className: onLanding ? "header-minimal z-50" : "header-minimal sticky top-0 z-50",
|
||||
style: headerStyle
|
||||
@@ -21828,7 +21830,7 @@ Right-click or Ctrl+click to disconnect`,
|
||||
window.EnhancedMinimalHeader = EnhancedMinimalHeader;
|
||||
|
||||
// src/components/ui/DownloadApps.jsx
|
||||
var DESKTOP_VERSION = "0.3.0";
|
||||
var DESKTOP_VERSION = "0.5.0";
|
||||
var DESKTOP_RELEASE = `https://github.com/SecureBitChat/securebit-desktop/releases/download/v${DESKTOP_VERSION}`;
|
||||
var DownloadApps = () => {
|
||||
const apps = [
|
||||
|
||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
Vendored
+306
-9
@@ -2698,6 +2698,167 @@ function createGroupSender({
|
||||
};
|
||||
}
|
||||
|
||||
// src/ui/motion.js
|
||||
var SPRING = {
|
||||
// Move / reposition something. No overshoot: a panel that bounces when it
|
||||
// was not thrown reads as decoration.
|
||||
move: { damping: 1, response: 0.4 },
|
||||
// A sheet or drawer the user dragged. The bounce is earned — the gesture
|
||||
// carried momentum into it.
|
||||
drawer: { damping: 0.8, response: 0.3 },
|
||||
// Rotation.
|
||||
rotate: { damping: 0.8, response: 0.4 }
|
||||
};
|
||||
var DT = 1 / 480;
|
||||
var MAX_FRAME = 0.064;
|
||||
function prefersReducedMotion() {
|
||||
try {
|
||||
return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function project(velocity, decelerationRate = 0.998) {
|
||||
return velocity / 1e3 * decelerationRate / (1 - decelerationRate);
|
||||
}
|
||||
function snapTarget(position, velocity, points, o = {}) {
|
||||
const { flick = 220, decelerationRate = 0.998 } = o;
|
||||
if (!points || !points.length) return position;
|
||||
const projected = position + project(velocity, decelerationRate);
|
||||
let pool = points;
|
||||
if (Math.abs(velocity) > flick) {
|
||||
const ahead = points.filter((p) => velocity > 0 ? p >= position : p <= position);
|
||||
if (ahead.length) pool = ahead;
|
||||
}
|
||||
return pool.reduce(
|
||||
(best, p) => Math.abs(p - projected) < Math.abs(best - projected) ? p : best,
|
||||
pool[0]
|
||||
);
|
||||
}
|
||||
function rubberband(overshoot, dimension, constant = 0.55) {
|
||||
if (!dimension) return 0;
|
||||
return overshoot * dimension * constant / (dimension + constant * Math.abs(overshoot));
|
||||
}
|
||||
function velocityTracker(windowMs = 100) {
|
||||
const samples = [];
|
||||
return {
|
||||
add(value, time = performance.now()) {
|
||||
samples.push({ value, time });
|
||||
while (samples.length > 2 && time - samples[0].time > windowMs) samples.shift();
|
||||
},
|
||||
/** @returns {number} px/s over the trailing window */
|
||||
get(now = performance.now()) {
|
||||
if (samples.length < 2) return 0;
|
||||
const last = samples[samples.length - 1];
|
||||
if (now - last.time > windowMs) return 0;
|
||||
const first = samples[0];
|
||||
const dt = (last.time - first.time) / 1e3;
|
||||
if (dt <= 0) return 0;
|
||||
return (last.value - first.value) / dt;
|
||||
},
|
||||
reset() {
|
||||
samples.length = 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
function spring(o) {
|
||||
const {
|
||||
from,
|
||||
to,
|
||||
velocity = 0,
|
||||
damping = SPRING.move.damping,
|
||||
response = SPRING.move.response,
|
||||
restDelta = 0.1,
|
||||
restSpeed = 0.5,
|
||||
respectReducedMotion = true,
|
||||
onUpdate,
|
||||
onComplete
|
||||
} = o;
|
||||
let value = from;
|
||||
let vel = velocity;
|
||||
let target = to;
|
||||
let raf = 0;
|
||||
let last = 0;
|
||||
let done = false;
|
||||
const omega = 2 * Math.PI / response;
|
||||
const zeta = damping;
|
||||
const finish = () => {
|
||||
done = true;
|
||||
raf = 0;
|
||||
value = target;
|
||||
vel = 0;
|
||||
if (onUpdate) onUpdate(value);
|
||||
if (onComplete) onComplete();
|
||||
};
|
||||
if (respectReducedMotion && prefersReducedMotion()) {
|
||||
finish();
|
||||
return {
|
||||
get value() {
|
||||
return value;
|
||||
},
|
||||
get velocity() {
|
||||
return 0;
|
||||
},
|
||||
get done() {
|
||||
return true;
|
||||
},
|
||||
retarget(next) {
|
||||
target = next;
|
||||
finish();
|
||||
},
|
||||
stop() {
|
||||
}
|
||||
};
|
||||
}
|
||||
const step = (now) => {
|
||||
raf = 0;
|
||||
const frame = Math.min((now - last) / 1e3, MAX_FRAME);
|
||||
last = now;
|
||||
let t = frame;
|
||||
while (t > 0) {
|
||||
const dt = Math.min(DT, t);
|
||||
const accel = -omega * omega * (value - target) - 2 * zeta * omega * vel;
|
||||
vel += accel * dt;
|
||||
value += vel * dt;
|
||||
t -= dt;
|
||||
}
|
||||
if (Math.abs(value - target) < restDelta && Math.abs(vel) < restSpeed) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
if (onUpdate) onUpdate(value);
|
||||
raf = requestAnimationFrame(step);
|
||||
};
|
||||
last = performance.now();
|
||||
raf = requestAnimationFrame(step);
|
||||
return {
|
||||
get value() {
|
||||
return value;
|
||||
},
|
||||
get velocity() {
|
||||
return vel;
|
||||
},
|
||||
get done() {
|
||||
return done;
|
||||
},
|
||||
/**
|
||||
* Point the spring somewhere else without touching its current velocity.
|
||||
*
|
||||
* This is the anti-"brick wall" move. Killing this animation and starting
|
||||
* a new one at velocity 0 puts a discontinuity exactly where the user
|
||||
* reversed direction, which is the moment they are most attentive.
|
||||
*/
|
||||
retarget(next) {
|
||||
target = next;
|
||||
},
|
||||
stop() {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
done = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// src/components/ui/GroupChat.jsx
|
||||
var h = (...args) => React.createElement(...args);
|
||||
var C = {
|
||||
@@ -3476,6 +3637,7 @@ function GroupChatView({
|
||||
}
|
||||
|
||||
// src/app.jsx
|
||||
var scrollBehavior = () => prefersReducedMotion() ? "auto" : "smooth";
|
||||
var copyToClipboardSecure = async (text, autoClearMs = 0) => {
|
||||
let ok = false;
|
||||
try {
|
||||
@@ -4879,7 +5041,7 @@ var EnhancedConnectionSetup = ({
|
||||
}, disabled: !hasInvite || connectionStatus === "connecting", style: { width: "100%", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "9px", padding: "14px", borderRadius: "13px", border: "none", background: hasInvite && connectionStatus !== "connecting" ? C_ORANGE : "rgba(255,255,255,0.05)", color: hasInvite && connectionStatus !== "connecting" ? "#1a0f04" : "#56565e", fontFamily: "inherit", fontSize: "15px", fontWeight: 700, cursor: hasInvite && connectionStatus !== "connecting" ? "pointer" : "not-allowed", boxShadow: hasInvite && connectionStatus !== "connecting" ? "0 8px 24px rgba(240,137,42,0.28)" : "none" } }, connectionStatus === "connecting" ? "Processing\u2026" : "Connect")
|
||||
]);
|
||||
}
|
||||
const SB_DESKTOP_VERSION = "0.3.0";
|
||||
const SB_DESKTOP_VERSION = "0.5.0";
|
||||
const SB_DESKTOP_RELEASE = `https://github.com/SecureBitChat/securebit-desktop/releases/download/v${SB_DESKTOP_VERSION}`;
|
||||
const DOWNLOADS = {
|
||||
mac: { name: "macOS", format: ".dmg \xB7 Apple Silicon & Intel", icon: "fab fa-apple", url: `${SB_DESKTOP_RELEASE}/SecureBit.Chat_${SB_DESKTOP_VERSION}_x64.dmg` },
|
||||
@@ -5028,7 +5190,7 @@ var createScrollToBottomFunction = (chatMessagesRef) => {
|
||||
if (chatMessagesRef.current) {
|
||||
chatMessagesRef.current.scrollTo({
|
||||
top: chatMessagesRef.current.scrollHeight,
|
||||
behavior: "smooth"
|
||||
behavior: scrollBehavior()
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -5417,7 +5579,7 @@ var EnhancedChatInterface = ({
|
||||
if (chatMessagesRef.current) {
|
||||
chatMessagesRef.current.scrollTo({
|
||||
top: chatMessagesRef.current.scrollHeight,
|
||||
behavior: "smooth"
|
||||
behavior: scrollBehavior()
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -5439,7 +5601,7 @@ var EnhancedChatInterface = ({
|
||||
scrollToBottom();
|
||||
setShowScrollButton(false);
|
||||
} else if (chatMessagesRef.current) {
|
||||
chatMessagesRef.current.scrollTo({ top: chatMessagesRef.current.scrollHeight, behavior: "smooth" });
|
||||
chatMessagesRef.current.scrollTo({ top: chatMessagesRef.current.scrollHeight, behavior: scrollBehavior() });
|
||||
setShowScrollButton(false);
|
||||
}
|
||||
};
|
||||
@@ -5790,6 +5952,134 @@ var SessionsSidebar = ({ chats, groups = [], collapsed, drawerOpen, onToggleColl
|
||||
const [editingId, setEditingId] = React.useState(null);
|
||||
const [draft, setDraft] = React.useState("");
|
||||
const [presenceOpen, setPresenceOpen] = React.useState(false);
|
||||
const scrimRef = React.useRef(null);
|
||||
const panelRef = React.useRef(null);
|
||||
const animRef = React.useRef(null);
|
||||
const dragRef = React.useRef(null);
|
||||
const offsetRef = React.useRef(0);
|
||||
const openRef = React.useRef(false);
|
||||
const clickGuardRef = React.useRef(false);
|
||||
const [drawerMounted, setDrawerMounted] = React.useState(!!drawerOpen);
|
||||
const AXIS_LOCK = 10;
|
||||
const FLICK = 220;
|
||||
const drawerWidth = () => panelRef.current && panelRef.current.offsetWidth || 292;
|
||||
const paintDrawer = (x) => {
|
||||
offsetRef.current = x;
|
||||
const w = drawerWidth();
|
||||
const progress = Math.min(1, Math.max(0, 1 + x / w));
|
||||
if (panelRef.current) panelRef.current.style.transform = "translate3d(" + x.toFixed(2) + "px,0,0)";
|
||||
if (scrimRef.current) {
|
||||
scrimRef.current.style.opacity = progress.toFixed(3);
|
||||
const blur = "blur(" + (5 * progress).toFixed(2) + "px)";
|
||||
scrimRef.current.style.backdropFilter = blur;
|
||||
scrimRef.current.style.webkitBackdropFilter = blur;
|
||||
}
|
||||
};
|
||||
const settleDrawer = (to, velocity) => {
|
||||
if (animRef.current) animRef.current.stop();
|
||||
animRef.current = spring({
|
||||
from: offsetRef.current,
|
||||
to,
|
||||
velocity,
|
||||
// Overshoot has to be earned. A drawer that was flicked carries
|
||||
// momentum and should land with a little bounce; one that was
|
||||
// opened by tapping the burger has none, and wobbling would be
|
||||
// the interface talking to itself.
|
||||
damping: Math.abs(velocity) > 60 ? SPRING.drawer.damping : SPRING.move.damping,
|
||||
response: SPRING.drawer.response,
|
||||
restDelta: 0.5,
|
||||
restSpeed: 8,
|
||||
onUpdate: paintDrawer,
|
||||
onComplete: () => {
|
||||
paintDrawer(to);
|
||||
animRef.current = null;
|
||||
if (to !== 0) setDrawerMounted(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
React.useLayoutEffect(() => {
|
||||
if (drawerOpen && !drawerMounted) {
|
||||
setDrawerMounted(true);
|
||||
return;
|
||||
}
|
||||
if (!drawerMounted) return;
|
||||
if (drawerOpen === openRef.current) return;
|
||||
const w = drawerWidth();
|
||||
if (drawerOpen && !animRef.current && !dragRef.current) {
|
||||
paintDrawer(-w);
|
||||
}
|
||||
openRef.current = drawerOpen;
|
||||
settleDrawer(drawerOpen ? 0 : -w, animRef.current ? animRef.current.velocity : 0);
|
||||
}, [drawerOpen, drawerMounted]);
|
||||
React.useEffect(() => () => {
|
||||
if (animRef.current) animRef.current.stop();
|
||||
}, []);
|
||||
const drawerDown = (e) => {
|
||||
if (e.pointerType === "mouse" && e.button !== 0) return;
|
||||
if (dragRef.current) return;
|
||||
clickGuardRef.current = false;
|
||||
dragRef.current = {
|
||||
id: e.pointerId,
|
||||
x0: e.clientX,
|
||||
y0: e.clientY,
|
||||
// Where they grabbed is where it stays. Re-centring the panel
|
||||
// under the finger breaks the illusion on the first frame.
|
||||
base: offsetRef.current,
|
||||
axis: null,
|
||||
vel: velocityTracker()
|
||||
};
|
||||
dragRef.current.vel.add(e.clientX, e.timeStamp || performance.now());
|
||||
};
|
||||
const drawerMove = (e) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || e.pointerId !== d.id) return;
|
||||
const dx = e.clientX - d.x0;
|
||||
const dy = e.clientY - d.y0;
|
||||
if (!d.axis) {
|
||||
if (Math.abs(dx) < AXIS_LOCK && Math.abs(dy) < AXIS_LOCK) return;
|
||||
if (Math.abs(dy) >= Math.abs(dx)) {
|
||||
dragRef.current = null;
|
||||
return;
|
||||
}
|
||||
d.axis = "x";
|
||||
if (animRef.current) {
|
||||
d.base = offsetRef.current;
|
||||
d.x0 = e.clientX;
|
||||
animRef.current.stop();
|
||||
animRef.current = null;
|
||||
}
|
||||
try {
|
||||
e.currentTarget.setPointerCapture(d.id);
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
d.vel.add(e.clientX, e.timeStamp || performance.now());
|
||||
const w = drawerWidth();
|
||||
let x = d.base + (e.clientX - d.x0);
|
||||
if (x > 0) x = rubberband(x, w);
|
||||
else if (x < -w) x = -w + rubberband(x + w, w);
|
||||
paintDrawer(x);
|
||||
};
|
||||
const drawerUp = (e) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || e.pointerId !== void 0 && e.pointerId !== d.id) return;
|
||||
dragRef.current = null;
|
||||
if (d.axis !== "x") return;
|
||||
clickGuardRef.current = true;
|
||||
const w = drawerWidth();
|
||||
const v = d.vel.get(e.timeStamp || performance.now());
|
||||
const open = snapTarget(offsetRef.current, v, [-w, 0], { flick: FLICK }) === 0;
|
||||
openRef.current = open;
|
||||
settleDrawer(open ? 0 : -w, v);
|
||||
if (!open) onCloseDrawer();
|
||||
};
|
||||
const drawerScrimClick = () => {
|
||||
if (clickGuardRef.current) {
|
||||
clickGuardRef.current = false;
|
||||
return;
|
||||
}
|
||||
onCloseDrawer();
|
||||
};
|
||||
const startEdit = (c) => (e) => {
|
||||
e.stopPropagation();
|
||||
setEditingId(c.id);
|
||||
@@ -5998,13 +6288,20 @@ var SessionsSidebar = ({ chats, groups = [], collapsed, drawerOpen, onToggleColl
|
||||
h2("style", { key: "css", dangerouslySetInnerHTML: { __html: "@media (max-width:1023px){.sb-rail{display:none !important;}.sb-burger{display:grid !important;}}@media (min-width:1024px){.sb-drawer-overlay{display:none !important;}}.sb-mobile-drawer .sb-collapse-btn{display:none !important;}html,body{background:#0f0f11 !important;overscroll-behavior:none;}.sb-app-shell{height:var(--sb-vh,100dvh) !important;min-height:0 !important;overflow:hidden;}.sb-chat-header{position:sticky;top:0;z-index:20;}.sb-app-col{height:100% !important;min-height:0 !important;}.chat-container{height:100% !important;min-height:0 !important;}.sb-scroll{min-height:0 !important;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;}@media (max-width:768px){textarea,input,select{font-size:16px !important;}}@media (max-width:768px){.sb-rename-btn{display:none !important;}}" } }),
|
||||
// Desktop rail
|
||||
h2("aside", { key: "rail", className: "sb-rail", style: railStyle }, inner),
|
||||
// Mobile drawer overlay
|
||||
// Mobile drawer overlay. `touch-action: pan-y` hands vertical
|
||||
// scrolling back to the browser and keeps the horizontal axis for
|
||||
// us, so the chat list still scrolls with the drawer open.
|
||||
h2("div", {
|
||||
key: "drawer",
|
||||
className: "sb-drawer-overlay",
|
||||
onClick: onCloseDrawer,
|
||||
style: { position: "fixed", inset: 0, zIndex: 60, background: "rgba(6,6,8,0.6)", backdropFilter: "blur(4px)", WebkitBackdropFilter: "blur(4px)", display: drawerOpen ? "block" : "none" }
|
||||
}, h2("aside", { className: "sb-mobile-drawer", onClick: (e) => e.stopPropagation(), style: { position: "absolute", left: 0, top: 0, bottom: 0, width: "min(292px, 86vw)", display: "flex", flexDirection: "column", background: "#0c0c0e", borderRight: "1px solid rgba(255,255,255,0.06)", boxShadow: "0 0 60px rgba(0,0,0,0.6)" } }, [
|
||||
ref: scrimRef,
|
||||
onClick: drawerScrimClick,
|
||||
onPointerDown: drawerDown,
|
||||
onPointerMove: drawerMove,
|
||||
onPointerUp: drawerUp,
|
||||
onPointerCancel: drawerUp,
|
||||
style: { position: "fixed", inset: 0, zIndex: 60, background: "rgba(6,6,8,0.6)", backdropFilter: "blur(0px)", WebkitBackdropFilter: "blur(0px)", opacity: 0, display: drawerMounted ? "block" : "none", touchAction: "pan-y", willChange: "opacity" }
|
||||
}, h2("aside", { className: "sb-mobile-drawer", ref: panelRef, onClick: (e) => e.stopPropagation(), style: { position: "absolute", left: 0, top: 0, bottom: 0, width: "min(292px, 86vw)", display: "flex", flexDirection: "column", background: "#0c0c0e", borderRight: "1px solid rgba(255,255,255,0.06)", boxShadow: "0 0 60px rgba(0,0,0,0.6)", touchAction: "pan-y", willChange: "transform" } }, [
|
||||
// Explicit close button — the drawer's own header only has a
|
||||
// "collapse" chevron (a desktop-rail action), so on mobile there was
|
||||
// no obvious way to dismiss it. This X closes the drawer reliably.
|
||||
@@ -6447,7 +6744,7 @@ var EnhancedSecureP2PChat = () => {
|
||||
if (container && container.scrollTo) {
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: "smooth"
|
||||
behavior: scrollBehavior()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Vendored
+3
-3
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user