1.0.80
All checks were successful
Build and Release / build-and-release (push) Successful in 13m27s

This commit is contained in:
Bryan1029384756
2026-04-15 20:06:44 -05:00
parent f2dc627b76
commit 26dd013b05
16 changed files with 398 additions and 18 deletions

View File

@@ -8,7 +8,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 27
versionName "1.0.70"
versionName "1.0.80"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -1,7 +1,7 @@
{
"name": "@discord-clone/electron",
"private": true,
"version": "1.0.70",
"version": "1.0.80",
"description": "Brycord - Electron app",
"author": "Moyettes",
"type": "module",

View File

@@ -1,7 +1,7 @@
{
"name": "@discord-clone/web",
"private": true,
"version": "1.0.70",
"version": "1.0.80",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -147,6 +147,37 @@ if (window.Capacitor?.isNativePlatform?.()) {
webPlatform.systemBars = { setColors: (opts) => SystemBars.setColors(opts) };
webPlatform.features.hasSystemBars = true;
}
// Android hardware back button → dispatch to the app-level registry.
// We expose a `detail.handled` flag so in-tree listeners (see
// `useBackHandler`) can claim the event. When nothing claims it, we
// fall back to `App.minimizeApp()` so the user lands on their home
// screen instead of force-exiting the process.
const AppPlugin = window.Capacitor.Plugins.App;
if (AppPlugin && typeof AppPlugin.addListener === 'function') {
AppPlugin.addListener('backButton', () => {
const detail = { handled: false };
try {
window.dispatchEvent(
new CustomEvent('brycord:android-back', { detail }),
);
} catch (e) {
console.warn('[backButton] dispatch failed', e);
}
if (!detail.handled) {
try {
if (typeof AppPlugin.minimizeApp === 'function') {
AppPlugin.minimizeApp();
} else if (typeof AppPlugin.exitApp === 'function') {
AppPlugin.exitApp();
}
} catch (e) {
console.warn('[backButton] fallback failed', e);
}
}
});
webPlatform.features.hasBackButton = true;
}
}
export default webPlatform;

View File

@@ -1,7 +1,7 @@
{
"name": "@discord-clone/shared",
"private": true,
"version": "1.0.70",
"version": "1.0.80",
"type": "module",
"main": "src/App.tsx",
"dependencies": {

View File

@@ -216,11 +216,20 @@ interface LinkEmbedProps {
}
export function LinkEmbed({ url, onOpenGif }: LinkEmbedProps) {
// Thin router: direct media gets its own component, rich previews
// get theirs. Keeping the two paths in separate component instances
// means `useUrlPreview` (which internally wraps `useAction` and
// therefore `useMemo`) is never called conditionally — otherwise a
// URL change that flipped `isDirectMedia` would mutate the hook
// count and trigger React error #310.
const directType = isDirectMedia(url);
if (directType) {
return <DirectMediaEmbed url={url} type={directType} onOpenGif={onOpenGif} />;
}
return <UrlPreviewEmbed url={url} />;
}
function UrlPreviewEmbed({ url }: { url: string }) {
const preview = useUrlPreview(url);
// If the platform has no metadata fetcher (or it returned null / threw),

View File

@@ -1,6 +1,10 @@
import { useEffect, useState } from 'react';
import { Navigate, Outlet, useNavigate } from 'react-router-dom';
import { useEffect, useRef, useState } from 'react';
import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { PresenceProvider } from '../../contexts/PresenceContext';
import { usePlatform } from '../../platform';
import { getUserPref, setUserPref } from '../../utils/userPreferences';
import { useMobileSwipeNav } from '../../hooks/useMobileSwipeNav';
import { triggerBack, useBackHandler } from '../../hooks/useBackHandler';
import { KeybindProvider } from '../../contexts/KeybindContext';
import { UserSettingsModal } from '../settings/UserSettingsModal';
import { ServerSettingsModal } from '../settings/ServerSettingsModal';
@@ -23,6 +27,10 @@ import { UpdateBanner } from './UpdateBanner';
*/
export function AppLayout() {
const navigate = useNavigate();
const location = useLocation();
const platform = usePlatform() as any;
const hasRestoredRef = useRef(false);
useMobileSwipeNav();
const [settingsOpen, setSettingsOpen] = useState(false);
// Optional tab request from the dispatcher — e.g. Edit Profile
// buttons pass `{ tab: 'account' }` to jump straight in, while the
@@ -47,6 +55,39 @@ export function AppLayout() {
return () => window.removeEventListener('brycord:navigate', onNavigate);
}, [navigate]);
// Restore last-viewed route once on mount. Only fires when the user
// landed on the default `/channels/@me` (i.e. a fresh app open or a
// post-login redirect) — if they deep-linked to a specific channel
// we respect that and skip restoration.
useEffect(() => {
if (hasRestoredRef.current) return;
hasRestoredRef.current = true;
const userId = localStorage.getItem('userId');
if (!userId) return;
if (location.pathname !== '/channels/@me') return;
const saved = getUserPref(userId, 'lastViewedRoute', null);
if (
typeof saved === 'string' &&
saved.startsWith('/channels/') &&
saved !== location.pathname
) {
navigate(saved, { replace: true });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Persist the current route on every change so the next cold-open
// can resume where the user left off. Skip the mobile `/channels/you`
// profile tab — restoring to it would feel weird after a restart,
// and skip paths that aren't inside the authenticated shell.
useEffect(() => {
if (!location.pathname.startsWith('/channels/')) return;
if (location.pathname === '/channels/you') return;
const userId = localStorage.getItem('userId');
if (!userId) return;
setUserPref(userId, 'lastViewedRoute', location.pathname, platform?.settings);
}, [location.pathname, platform]);
// User settings trigger — fired by UserArea's gear button + other entry points
useEffect(() => {
const onOpenSettings = (e: Event) => {
@@ -86,6 +127,46 @@ export function AppLayout() {
window.removeEventListener('brycord:open-channel-settings', onOpen);
}, []);
// Register the non-`Modal.Root` modals (UserSettings / ServerSettings
// / CreateServer render their own chrome) with the back-handler stack.
// Modals built on `Modal.Root` and `BottomSheet` self-register from
// inside those primitives, so they don't need explicit wiring here.
useBackHandler(settingsOpen, () => setSettingsOpen(false));
useBackHandler(serverSettingsOpen, () => setServerSettingsOpen(false));
useBackHandler(createServerOpen, () => setCreateServerOpen(false));
// Global Android back-button coordinator. Runs registered overlay
// handlers first (topmost drawer wins), then falls back to
// navigating chat → nav on mobile, then marks the event unhandled
// so the Capacitor bridge minimizes the app.
useEffect(() => {
const onBack = (e: Event) => {
const detail = (e as CustomEvent<{ handled?: boolean }>).detail;
if (detail?.handled) return;
if (triggerBack()) {
if (detail) detail.handled = true;
return;
}
// Mobile fallback: if we're in chat mode, go back to the
// channel list instead of exiting the app.
const isMobile =
typeof window !== 'undefined' &&
window.matchMedia('(max-width: 768px)').matches;
if (isMobile) {
const segments = location.pathname.split('/').filter(Boolean);
if (segments[0] === 'channels' && segments.length >= 3 && segments[2]) {
navigate(`/channels/${segments[1]}`);
if (detail) detail.handled = true;
return;
}
}
};
window.addEventListener('brycord:android-back', onBack);
return () => window.removeEventListener('brycord:android-back', onBack);
}, [location.pathname, navigate]);
// Global keybind handlers. Voice bindings are wired from UserArea
// via its own `useVoice()` access so the dispatch arrives where the
// mute / deafen state already lives.

View File

@@ -32,6 +32,7 @@ import { GuildHeaderDropdown } from './GuildHeaderDropdown';
import { ChannelListContextMenu } from './ChannelListContextMenu';
import { MobileServerActionsSheet } from './MobileServerActionsSheet';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useBackHandler } from '../../hooks/useBackHandler';
import { VoiceUserContextMenu } from '../voice/VoiceUserContextMenu';
import { MemberProfileModal } from '../member/MemberProfileModal';
import styles from './GuildNavbar.module.css';
@@ -151,6 +152,9 @@ export function GuildNavbar() {
>(null);
const [voiceProfileFor, setVoiceProfileFor] = useState<string | null>(null);
const isMobile = useIsMobile();
useBackHandler(mobileServerSheetOpen, () => setMobileServerSheetOpen(false));
useBackHandler(!!voiceMenu, () => setVoiceMenu(null));
useBackHandler(!!voiceProfileFor, () => setVoiceProfileFor(null));
const categories = useMemo<CategoryDoc[]>(
() =>

View File

@@ -18,6 +18,7 @@ import { Gear, PencilSimple } from '@phosphor-icons/react';
import { Avatar } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import { MobileSetStatusSheet } from './MobileSetStatusSheet';
import { useBackHandler } from '../../hooks/useBackHandler';
import styles from './MobileYouPage.module.css';
function openUserSettings(tab?: 'account') {
@@ -51,6 +52,7 @@ export function MobileYouPage() {
const allUsers = useQuery(api.auth.getPublicKeys) ?? [];
const me = allUsers.find((u) => u.id === userId);
const [statusSheetOpen, setStatusSheetOpen] = useState(false);
useBackHandler(statusSheetOpen, () => setStatusSheetOpen(false));
const displayName = me?.displayName || username || 'User';
const realName = me?.username || username || displayName;

View File

@@ -60,6 +60,17 @@ export function ServerSettingsModal({ isOpen, onClose, initialTab }: ServerSetti
return () => document.removeEventListener('keydown', onKey);
}, [isOpen, onClose]);
// Roles view takes over the whole settings surface — its own
// sidebar (back + create + role list) and its own main column
// (role editor). We still delegate the back button to flipping
// activeTab back to 'overview' so the outer modal stays open.
//
// IMPORTANT: this hook must run before any conditional early return
// (mobile shortcut below, `!isOpen` guard) so the hook count stays
// stable across renders when the viewport crosses the mobile
// breakpoint. React error #300 if this moves back under `isMobile`.
const rolesView = useRolesView({ onBack: () => setActiveTab('overview') });
// Mobile gets the full-screen overlay with a category list ↔ panel
// flow, desktop gets the two-column modal below. Pass the raw
// `initialTab` (NOT the resolved version) so mobile lands on the
@@ -74,12 +85,6 @@ export function ServerSettingsModal({ isOpen, onClose, initialTab }: ServerSetti
);
}
// Roles view takes over the whole settings surface — its own
// sidebar (back + create + role list) and its own main column
// (role editor). We still delegate the back button to flipping
// activeTab back to 'overview' so the outer modal stays open.
const rolesView = useRolesView({ onBack: () => setActiveTab('overview') });
if (!isOpen) return null;
const active = TABS.find((t) => t.id === activeTab) ?? TABS[0];

View File

@@ -86,6 +86,15 @@ export function RecordingRecoveryModal() {
};
}, [hasRecording, recording]);
// Close the modal automatically once every session has been
// resolved. Declared here (before the `!hasRecording` early return)
// so the hook count stays stable across renders.
useEffect(() => {
if (isOpen && sessions.length === 0) {
setIsOpen(false);
}
}, [isOpen, sessions.length]);
if (!hasRecording) return null;
const dismiss = () => {
@@ -139,12 +148,6 @@ export function RecordingRecoveryModal() {
}
};
useEffect(() => {
if (isOpen && sessions.length === 0) {
setIsOpen(false);
}
}, [isOpen, sessions.length]);
return (
<Modal.Root isOpen={isOpen} onClose={dismiss} size="medium">
<Modal.Header title="Recover voice recordings" onClose={dismiss} />

View File

@@ -0,0 +1,58 @@
import { useEffect } from 'react';
/**
* Back-button handler registry.
*
* Components that render a dismissible overlay (modal, drawer, bottom
* sheet, popover, image lightbox, …) call `useBackHandler(isOpen, onBack)`
* to register a close callback while the overlay is visible. When the
* Android hardware back button fires, the most-recently-registered open
* handler runs — LIFO order, so the topmost drawer always closes first.
*
* The stack is stored on `window.__brycordBackStack` so other packages
* (notably `@discord-clone/ui`'s Modal / BottomSheet primitives) can push
* into the same queue without a direct dependency on this package.
*/
type BackHandler = () => void;
function getStack(): BackHandler[] {
const g = typeof window !== 'undefined' ? (window as any) : {};
if (!g.__brycordBackStack) g.__brycordBackStack = [];
return g.__brycordBackStack;
}
export function pushBackHandler(fn: BackHandler): () => void {
const stack = getStack();
stack.push(fn);
return () => {
const s = getStack();
const i = s.lastIndexOf(fn);
if (i !== -1) s.splice(i, 1);
};
}
/** Returns true if a registered handler claimed the event. */
export function triggerBack(): boolean {
const stack = getStack();
const top = stack[stack.length - 1];
if (!top) return false;
try {
top();
} catch (e) {
console.warn('[back] handler threw', e);
}
return true;
}
/**
* Register a close callback for the duration that `isOpen` is true.
* The callback is pushed on open and popped on close (or unmount).
*/
export function useBackHandler(isOpen: boolean, onBack: BackHandler) {
useEffect(() => {
if (!isOpen) return;
const pop = pushBackHandler(onBack);
return pop;
}, [isOpen, onBack]);
}

View File

@@ -0,0 +1,153 @@
import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
/**
* Mobile horizontal-swipe navigation.
*
* - In *chat* mode (`/channels/:serverId/:channelId` or `/channels/@me/:dmId`),
* a left→right swipe that starts inside the left edge zone navigates to
* the channel list (the parent path).
* - In *nav* mode (`/channels/:serverId` or `/channels/@me`), a right→left
* swipe that starts inside the right edge zone navigates to the *last*
* channel the user had open inside this server / DM scope. If nothing is
* remembered, the gesture is a no-op.
*
* Edge-zone activation (first/last 48px) keeps the gesture from fighting
* horizontal scroll inside the chat (code blocks, carousels) and prevents
* accidental swipes when the user is just tapping around.
*/
const EDGE_ZONE = 48;
const MIN_DX = 70;
const MAX_DY = 48;
const STORAGE_KEY_PREFIX = 'brycord:lastChannel:';
const MOBILE_QUERY = '(max-width: 768px)';
function parseLocation(pathname: string) {
const segments = pathname.split('/').filter(Boolean);
if (segments[0] !== 'channels') {
return { scope: null as string | null, channelId: null as string | null };
}
const scope = segments[1] ?? null;
const channelId = segments[2] ?? null;
return { scope, channelId };
}
function rememberChannel(scope: string, channelId: string) {
try {
localStorage.setItem(`${STORAGE_KEY_PREFIX}${scope}`, channelId);
} catch {}
}
function recallChannel(scope: string): string | null {
try {
return localStorage.getItem(`${STORAGE_KEY_PREFIX}${scope}`);
} catch {
return null;
}
}
export function useMobileSwipeNav() {
const location = useLocation();
const navigate = useNavigate();
// Remember the active channel per scope so nav → chat swipes know
// where to go. Runs every path change.
useEffect(() => {
const { scope, channelId } = parseLocation(location.pathname);
if (scope && channelId) {
rememberChannel(scope, channelId);
}
}, [location.pathname]);
useEffect(() => {
if (typeof window === 'undefined') return;
if (!window.matchMedia(MOBILE_QUERY).matches) return;
let startX = 0;
let startY = 0;
let tracking = false;
let direction: 'back' | 'forward' | null = null;
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length !== 1) return;
const touch = e.touches[0];
startX = touch.clientX;
startY = touch.clientY;
tracking = false;
direction = null;
const { scope, channelId } = parseLocation(location.pathname);
if (!scope) return;
const inChat = !!channelId;
const vw = window.innerWidth;
if (inChat && startX <= EDGE_ZONE) {
tracking = true;
direction = 'back';
} else if (!inChat && startX >= vw - EDGE_ZONE) {
const remembered = recallChannel(scope);
if (remembered) {
tracking = true;
direction = 'forward';
}
}
};
const onTouchMove = (e: TouchEvent) => {
if (!tracking) return;
const touch = e.touches[0];
const dx = touch.clientX - startX;
const dy = touch.clientY - startY;
if (Math.abs(dy) > MAX_DY) {
// Vertical scroll — abandon the swipe.
tracking = false;
direction = null;
return;
}
// Block the horizontal scroll parent from hijacking us while
// the gesture is in progress.
if (Math.abs(dx) > 8) {
try {
e.preventDefault();
} catch {}
}
};
const onTouchEnd = (e: TouchEvent) => {
if (!tracking) return;
tracking = false;
const touch = e.changedTouches[0];
if (!touch) return;
const dx = touch.clientX - startX;
const dy = touch.clientY - startY;
if (Math.abs(dy) > MAX_DY) return;
const { scope, channelId } = parseLocation(location.pathname);
if (!scope) return;
if (direction === 'back' && dx >= MIN_DX) {
// chat → nav: drop the channel segment.
navigate(`/channels/${scope}`);
} else if (direction === 'forward' && dx <= -MIN_DX && !channelId) {
const remembered = recallChannel(scope);
if (remembered) {
navigate(`/channels/${scope}/${remembered}`);
}
}
direction = null;
};
document.addEventListener('touchstart', onTouchStart, { passive: true });
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd, { passive: true });
document.addEventListener('touchcancel', onTouchEnd, { passive: true });
return () => {
document.removeEventListener('touchstart', onTouchStart);
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
document.removeEventListener('touchcancel', onTouchEnd);
};
}, [location.pathname, navigate]);
}

View File

@@ -108,6 +108,7 @@
* @property {boolean} hasSearch
* @property {boolean} hasVoiceService
* @property {boolean} hasSystemBars
* @property {boolean} [hasBackButton]
*/
/**

View File

@@ -95,6 +95,22 @@ export function BottomSheet({
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, handleEscape]);
// Register with the global Android back-button stack so the
// hardware back key closes the topmost open sheet. Non-dismissible
// sheets opt out. Shared LIFO stack is keyed on `window`.
useEffect(() => {
if (!isOpen || !dismissible) return;
if (typeof window === 'undefined') return;
const g = window as any;
if (!g.__brycordBackStack) g.__brycordBackStack = [];
const stack: Array<() => void> = g.__brycordBackStack;
stack.push(onClose);
return () => {
const i = stack.lastIndexOf(onClose);
if (i !== -1) stack.splice(i, 1);
};
}, [isOpen, dismissible, onClose]);
// Lock body scroll while the sheet is open — keeps the underlying page
// from scrolling behind the drawer on mobile.
useEffect(() => {

View File

@@ -62,6 +62,23 @@ function ModalRoot({ isOpen, onClose, size = 'small', children, className, zInde
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, handleEscape]);
// Register with the global back-handler stack so Android hardware
// back closes the topmost open modal (shared across packages via
// `window.__brycordBackStack`). No-op on environments without a
// window (SSR / tests).
useEffect(() => {
if (!isOpen) return;
if (typeof window === 'undefined') return;
const g = window as any;
if (!g.__brycordBackStack) g.__brycordBackStack = [];
const stack: Array<() => void> = g.__brycordBackStack;
stack.push(onClose);
return () => {
const i = stack.lastIndexOf(onClose);
if (i !== -1) stack.splice(i, 1);
};
}, [isOpen, onClose]);
return createPortal(
<AnimatePresence>
{isOpen && (