1.1.2
Some checks failed
Build and Release / build-and-release (push) Has been cancelled

This commit is contained in:
Bryan1029384756
2026-04-17 21:44:01 -05:00
parent a3f6e29d6a
commit ad54f60225
30 changed files with 2517 additions and 475 deletions

View File

@@ -8,7 +8,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 27
versionName "1.1.1"
versionName "1.1.2"
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.1.1",
"version": "1.1.2",
"description": "Brycord - Electron app",
"author": "Moyettes",
"type": "module",

View File

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

View File

@@ -159,6 +159,13 @@ export const getAll = query({
avatarUrl: string | null;
joinSoundUrl: string | null;
watchingStream: string | null;
// Unix ms of when this voiceState row was created — used by the
// sidebar to render the "time in channel" indicator for the
// current user. `_creationTime` is populated by Convex
// automatically on insert, and since the join/move/afk paths
// always delete + insert (never patch across channels) it also
// correctly restarts on a channel hop.
joinedAt: number;
}>> = {};
for (const s of states) {
@@ -183,6 +190,7 @@ export const getAll = query({
avatarUrl,
joinSoundUrl,
watchingStream: s.watchingStream ?? null,
joinedAt: s._creationTime,
});
}

View File

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

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256" class="_channelIcon_lst2n_220">
<defs>
<mask id="a">
<path fill="#fff" d="M0 0h256v256H0z"/>
<circle cx="192" cy="64" r="60" fill="#000"/>
</mask>
</defs>
<path mask="url(#a)" d="M224 84h-43.8l7.61-41.85a12 12 0 0 0-23.62-4.3L155.8 84h-39.6l7.61-41.85a12 12 0 1 0-23.62-4.3L91.8 84H48a12 12 0 0 0 0 24h39.44l-7.27 40H32a12 12 0 0 0 0 24h43.8l-7.61 41.85a12 12 0 0 0 9.66 14A11.4 11.4 0 0 0 80 228a12 12 0 0 0 11.8-9.86l8.4-46.14h39.6l-7.61 41.85a12 12 0 0 0 9.66 14 11.4 11.4 0 0 0 2.16.2 12 12 0 0 0 11.8-9.86L164.2 172H208a12 12 0 0 0 0-24h-39.44l7.27-40H224a12 12 0 0 0 0-24m-79.83 64h-39.61l7.27-40h39.61Z"/>
<path d="M245.665 52 218.93 78.825c2.28 6.335 3.225 16.945-6.595 30A7.97 7.97 0 0 1 206.5 112h-.565a8 8 0 0 1-5.66-2.345L176.145 85.5l-21.315 21.33a4.002 4.002 0 0 1-5.66-5.66l21.33-21.315-24.15-24.15a8 8 0 0 1 .65-11.89c12.71-10.255 24.875-8.24 30.2-6.57L204 10.35a8 8 0 0 1 11.315 0l30.345 30.34a8 8 0 0 1 .005 11.31"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256" class="_channelIcon_lst2n_220">
<defs>
<mask id="pinnedChannelMask">
<rect width="256" height="256" fill="white"/>
<circle cx="192" cy="64" r="60" fill="black"/>
</mask>
</defs>
<path mask="url(#pinnedChannelMask)" d="M160,32.25V223.69a8.29,8.29,0,0,1-3.91,7.18,8,8,0,0,1-9-.56l-65.57-51A4,4,0,0,1,80,176.16V79.84a4,4,0,0,1,1.55-3.15l65.57-51a8,8,0,0,1,10,.16A8.27,8.27,0,0,1,160,32.25ZM60,80H32A16,16,0,0,0,16,96v64a16,16,0,0,0,16,16H60a4,4,0,0,0,4-4V84A4,4,0,0,0,60,80Zm126.77,20.84a8,8,0,0,0-.72,11.3,24,24,0,0,1,0,31.72,8,8,0,1,0,12,10.58,40,40,0,0,0,0-52.88A8,8,0,0,0,186.74,100.84Zm40.89-26.17a8,8,0,1,0-11.92,10.66,64,64,0,0,1,0,85.34,8,8,0,1,0,11.92,10.66,80,80,0,0,0,0-106.66Z"/>
<g transform="translate(128, 0) scale(0.5)">
<path d="M235.33,104l-53.47,53.65c4.56,12.67,6.45,33.89-13.19,60A15.93,15.93,0,0,1,157,224c-.38,0-.75,0-1.13,0a16,16,0,0,1-11.32-4.69L96.29,171,53.66,213.66a8,8,0,0,1-11.32-11.32L85,159.71l-48.3-48.3A16,16,0,0,1,38,87.63c25.42-20.51,49.75-16.48,60.4-13.14L152,20.7a16,16,0,0,1,22.63,0l60.69,60.68A16,16,0,0,1,235.33,104Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -224,6 +224,13 @@ function DirectMediaEmbed({
// of web video; when the real metadata lands and differs slightly
// the ResizeObserver catches it, but the gross box is already
// there.
//
// Appending `#t=0.1` is a media-fragment hint that forces the
// browser to seek to 0.1s, which makes it decode and paint that
// frame as a pseudo-poster. Android WebView otherwise renders a
// blank rectangle for `<video preload="metadata">` because it
// only fetches container metadata, not frames.
const posterSrc = url.includes('#') ? url : `${url}#t=0.1`;
return (
<div className={`${styles.embed} ${styles.embedBare}`}>
<div
@@ -237,7 +244,7 @@ function DirectMediaEmbed({
<video
ref={videoRef}
className={styles.directVideo}
src={url}
src={posterSrc}
preload="metadata"
style={{ width: '100%', height: '100%' }}
onLoadedMetadata={() => {

View File

@@ -19,6 +19,7 @@ import {
import { DeleteConfirmationModal } from './DeleteConfirmationModal';
import { PinConfirmationModal } from './PinConfirmationModal';
import { ReactionsModal } from './ReactionsModal';
import { MobileReactionsDrawer } from './MobileReactionsDrawer';
import { Tooltip } from '@discord-clone/ui';
import { reactionKeyToName } from '../../utils/emojiLookup';
import type { PinnedMessage } from './PinnedMessageRow';
@@ -150,6 +151,11 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
const [reactionsModalMsgId, setReactionsModalMsgId] = useState<
string | null
>(null);
// Mobile equivalent of the reactions modal — opened from the
// "Reactions" row in `MobileMessageActionsSheet`.
const [mobileReactionsMsgId, setMobileReactionsMsgId] = useState<
string | null
>(null);
const longPressTimerRef = useRef<number | null>(null);
const isMobile = useIsMobile();
@@ -727,6 +733,36 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
);
})()}
{(() => {
// Mobile "who reacted" drawer — same message lookup
// pattern as the desktop modal so it tracks the latest
// reaction rows rather than a stale snapshot.
const targetMsg = mobileReactionsMsgId
? (messages.find((m) => m.id === mobileReactionsMsgId) ?? null)
: null;
return (
<MobileReactionsDrawer
isOpen={!!targetMsg}
onClose={() => setMobileReactionsMsgId(null)}
reactions={targetMsg?.reactions ?? []}
myUserId={myUserId}
customEmojiByName={customEmojiByName}
onRemoveOwnReaction={async (emoji) => {
if (!targetMsg || !myUserId) return;
try {
await removeReaction({
messageId: targetMsg.id as any,
userId: myUserId as any,
emoji,
});
} catch (err) {
console.error('Failed to remove reaction:', err);
}
}}
/>
);
})()}
{mobileSheetForMsg && (
<MobileMessageActionsSheet
isOpen={!!mobileSheetForMsg}
@@ -754,6 +790,11 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
anchor: null as any,
});
}}
onViewReactions={
mobileSheetForMsg.reactions.length > 0
? () => setMobileReactionsMsgId(mobileSheetForMsg.id)
: undefined
}
onReply={() => {
onReply?.(mobileSheetForMsg.id, first.authorName);
}}

View File

@@ -0,0 +1,53 @@
.body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 8px 16px 32px;
}
.actionList {
display: flex;
flex-direction: column;
gap: 0;
border-radius: 12px;
overflow: hidden;
}
.actionItem {
position: relative;
display: flex;
align-items: center;
gap: 14px;
width: 100%;
padding: 16px;
background-color: var(--background-secondary-alt);
border: none;
color: var(--text-primary, #fff);
font: inherit;
font-size: 15px;
font-weight: 600;
text-align: left;
cursor: pointer;
transition: background-color 0.1s;
-webkit-tap-highlight-color: transparent;
}
.actionItem:active {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.08));
}
.actionItem svg {
flex-shrink: 0;
color: var(--text-primary-muted, #a0a3a8);
}
.actionItem:not(:last-child)::after {
content: '';
position: absolute;
left: 1rem;
right: 1rem;
bottom: 0;
height: 1px;
background-color: var(--background-header-secondary);
pointer-events: none;
}

View File

@@ -0,0 +1,84 @@
/**
* MobileChannelActionsSheet — long-press menu for channel rows on
* mobile. Replaces the desktop hover-gear workflow with a bottom
* sheet drawer so touch users can reach the same actions.
*
* Actions: Mark As Read / Edit Channel. Edit Channel is only shown
* when the viewer has the `manage_channels` permission; the Mark As
* Read row is always available.
*/
import { Check, Gear, PushPin, PushPinSlash } from '@phosphor-icons/react';
import { BottomSheet } from '@discord-clone/ui';
import styles from './MobileChannelActionsSheet.module.css';
interface MobileChannelActionsSheetProps {
isOpen: boolean;
onClose: () => void;
channelName: string;
canEdit: boolean;
isPinned: boolean;
onMarkAsRead: () => void;
onTogglePin: () => void;
onEditChannel: () => void;
}
export function MobileChannelActionsSheet({
isOpen,
onClose,
channelName,
canEdit,
isPinned,
onMarkAsRead,
onTogglePin,
onEditChannel,
}: MobileChannelActionsSheetProps) {
const wrap = (fn: () => void) => () => {
fn();
onClose();
};
return (
<BottomSheet
isOpen={isOpen}
onClose={onClose}
title={`#${channelName}`}
initialHeightSvh={35}
expandable
>
<div className={styles.body}>
<div className={styles.actionList}>
<button
type="button"
className={styles.actionItem}
onClick={wrap(onMarkAsRead)}
>
<Check size={20} weight="bold" />
<span>Mark As Read</span>
</button>
<button
type="button"
className={styles.actionItem}
onClick={wrap(onTogglePin)}
>
{isPinned ? (
<PushPinSlash size={20} weight="fill" />
) : (
<PushPin size={20} weight="fill" />
)}
<span>{isPinned ? 'Unpin Channel' : 'Pin Channel'}</span>
</button>
{canEdit && (
<button
type="button"
className={styles.actionItem}
onClick={wrap(onEditChannel)}
>
<Gear size={20} weight="fill" />
<span>Edit Channel</span>
</button>
)}
</div>
</div>
</BottomSheet>
);
}

View File

@@ -0,0 +1,319 @@
.page {
position: fixed;
inset: 0;
z-index: var(--z-index-modal, 10000);
display: flex;
flex-direction: column;
background-color: var(--background-primary, #1e1f22);
color: var(--text-primary, #fff);
padding-top: env(safe-area-inset-top, 0);
}
.header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 8px;
border-bottom: 1px solid var(--background-header-secondary);
flex-shrink: 0;
}
.headerButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: transparent;
border: none;
color: var(--text-primary, #fff);
cursor: pointer;
border-radius: 8px;
-webkit-tap-highlight-color: transparent;
}
.headerButton:active {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.06));
}
.headerTitle {
flex: 1;
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--text-primary, #fff);
}
.headerAction {
padding: 8px 16px;
background: transparent;
border: none;
font-size: 16px;
font-weight: 600;
color: var(--text-primary-muted, #6d6f78);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.headerActionActive {
color: var(--brand-primary, #5865f2);
}
.headerAction:disabled {
cursor: default;
}
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 20px 16px 40px;
display: flex;
flex-direction: column;
gap: 8px;
}
.warning {
padding: 12px 14px;
border-radius: 10px;
background-color: color-mix(in srgb, var(--status-danger, #ef4444) 20%, transparent);
color: var(--text-primary, #fff);
font-size: 14px;
margin-bottom: 8px;
}
.fieldLabel {
font-size: 13px;
color: var(--text-primary-muted, #a0a3a8);
margin-top: 20px;
}
.input {
width: 100%;
padding: 16px;
border-radius: 14px;
border: none;
background-color: var(--background-tertiary, #1e1f22);
color: var(--text-primary, #fff);
font-size: 15px;
outline: none;
}
.input:focus {
background-color: var(--background-secondary, #2b2d31);
}
.input:disabled {
opacity: 0.6;
}
.textareaWrap {
position: relative;
}
.textarea {
width: 100%;
min-height: 150px;
padding: 16px;
padding-bottom: 36px;
border-radius: 14px;
border: none;
background-color: var(--background-tertiary, #1e1f22);
color: var(--text-primary, #fff);
font: inherit;
font-size: 15px;
resize: vertical;
outline: none;
}
.textarea:focus {
background-color: var(--background-secondary, #2b2d31);
}
.textarea:disabled {
opacity: 0.6;
}
.textareaCounter {
position: absolute;
right: 16px;
bottom: 12px;
font-size: 12px;
color: var(--text-primary-muted, #a0a3a8);
font-variant-numeric: tabular-nums;
pointer-events: none;
}
.pickerRow {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
margin-top: 16px;
padding: 16px;
border-radius: 14px;
border: none;
background-color: var(--background-tertiary, #1e1f22);
color: var(--text-primary, #fff);
font: inherit;
font-size: 15px;
font-weight: 600;
text-align: left;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.pickerRow:active {
background-color: var(--background-secondary, #2b2d31);
}
.pickerRow:disabled {
opacity: 0.6;
cursor: default;
}
.pickerIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
background-color: var(--background-secondary-alt, #2b2d31);
color: var(--text-primary, #fff);
flex-shrink: 0;
}
.pickerLabel {
flex: 1;
min-width: 0;
}
.pickerValue {
color: var(--text-primary-muted, #a0a3a8);
font-weight: 500;
max-width: 50%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pickerChevron {
color: var(--text-primary-muted, #a0a3a8);
flex-shrink: 0;
}
.status {
margin: 12px 0 0;
padding: 10px 14px;
border-radius: 10px;
font-size: 14px;
}
.statusSuccess {
background-color: color-mix(in srgb, var(--status-positive, #23a55a) 20%, transparent);
color: var(--status-positive, #23a55a);
}
.statusError {
background-color: color-mix(in srgb, var(--status-danger, #ef4444) 20%, transparent);
color: var(--status-danger, #ef4444);
}
.deleteButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-top: 32px;
padding: 14px 16px;
border-radius: 14px;
border: none;
background-color: color-mix(in srgb, var(--status-danger, #ef4444) 14%, transparent);
color: hsl(350, calc(90% * var(--saturation-factor, 1)), 65%);
font: inherit;
font-size: 15px;
font-weight: 600;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.deleteButton:active {
background-color: color-mix(in srgb, var(--status-danger, #ef4444) 22%, transparent);
}
.pickerSheetBody {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0 12px 24px;
}
.pickerOption {
display: flex;
align-items: center;
padding: 14px 12px;
border: none;
border-radius: 10px;
background-color: transparent;
color: var(--text-primary, #fff);
font: inherit;
font-size: 15px;
font-weight: 500;
text-align: left;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.pickerOption:active {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.06));
}
.pickerOptionActive {
background-color: var(--background-modifier-selected, rgba(88, 101, 242, 0.2));
color: var(--text-primary, #fff);
}
/* Delete confirmation — centered modal over the fullscreen page. */
.confirmOverlay {
position: fixed;
inset: 0;
z-index: 1;
background-color: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.confirmDialog {
width: 100%;
max-width: 400px;
padding: 20px;
border-radius: 12px;
background-color: var(--background-primary, #1e1f22);
display: flex;
flex-direction: column;
gap: 12px;
}
.confirmTitle {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--text-primary, #fff);
}
.confirmText {
margin: 0;
font-size: 14px;
color: var(--text-secondary, #b5bac1);
line-height: 1.45;
}
.confirmActions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
}

View File

@@ -0,0 +1,354 @@
/**
* MobileChannelSettingsPage — fullscreen "Channel Settings" page for
* mobile. Replaces the desktop `ChannelSettingsModal` inside
* `AppLayout` when `useIsMobile()` is true.
*
* Layout (matches the design mock):
* ┌─ ← Channel Settings Save ┐
* │ │
* │ Channel Name │
* │ [ input ] │
* │ │
* │ Channel Topic │
* │ [ textarea (1024 cap) ] │
* │ │
* │ ┌─ Category → Text Channels ────┐ │
* │ └───────────────────────────────┘ │
* │ │
* │ [ Delete Channel ] │
* └───────────────────────────────────┘
*
* Category change is wired via `api.channels.reorderChannels` with a
* single update (that mutation takes `categoryId` and `position` so
* this is the existing "move to category" path).
*/
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useMutation, useQuery } from 'convex/react';
import {
ArrowLeft,
CaretRight,
FolderPlus,
Trash,
} from '@phosphor-icons/react';
import { Button, BottomSheet } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import { useBackHandler } from '../../hooks/useBackHandler';
import styles from './MobileChannelSettingsPage.module.css';
const NAME_MAX = 100;
const TOPIC_MAX = 1024;
const UNCATEGORIZED_VALUE = '__uncategorized__';
interface MobileChannelSettingsPageProps {
isOpen: boolean;
onClose: () => void;
channelId: string | null;
}
export function MobileChannelSettingsPage({
isOpen,
onClose,
channelId,
}: MobileChannelSettingsPageProps) {
const channel = useQuery(
api.channels.get,
isOpen && channelId ? { id: channelId as Id<'channels'> } : 'skip',
);
const categories = useQuery(api.categories.list) ?? [];
const myUserId =
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
const myPerms = useQuery(
api.roles.getMyPermissions,
myUserId ? { userId: myUserId as Id<'userProfiles'> } : 'skip',
);
const canModify = !!myPerms?.manage_channels;
const renameChannel = useMutation(api.channels.rename);
const updateTopic = useMutation(api.channels.updateTopic);
const removeChannel = useMutation(api.channels.remove);
const reorderChannels = useMutation(api.channels.reorderChannels);
const [name, setName] = useState('');
const [topic, setTopic] = useState('');
const [categoryId, setCategoryId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [status, setStatus] = useState<
{ type: 'success' | 'error'; message: string } | null
>(null);
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
// Seed local form state whenever the page (re)opens against a
// different channel, so stale edits never leak across switches.
useEffect(() => {
if (isOpen && channel) {
setName(channel.name || '');
setTopic(channel.topic || '');
setCategoryId(channel.categoryId ?? null);
setStatus(null);
setConfirmDelete(false);
setShowCategoryPicker(false);
}
}, [isOpen, channel?._id, channel?.name, channel?.topic, channel?.categoryId]);
// Android back button closes the page before falling through to the
// global back-to-nav fallback.
useBackHandler(isOpen && !showCategoryPicker && !confirmDelete, onClose);
if (!isOpen) return null;
if (!channel) return null;
const trimmedName = name.trim();
const trimmedTopic = topic.trim();
const originalName = (channel.name || '').trim();
const originalTopic = (channel.topic || '').trim();
const originalCategory = channel.categoryId ?? null;
const isDirty =
trimmedName !== originalName ||
trimmedTopic !== originalTopic ||
categoryId !== originalCategory;
const nameValid = trimmedName.length > 0 && trimmedName.length <= NAME_MAX;
const topicValid = trimmedTopic.length <= TOPIC_MAX;
const selectedCategoryName =
categoryId === null
? 'Uncategorized'
: (categories.find((c) => c._id === categoryId)?.name ?? 'Uncategorized');
const handleSave = async () => {
if (!canModify || !isDirty || !nameValid || !topicValid) return;
setSaving(true);
setStatus(null);
try {
if (trimmedName !== originalName) {
await renameChannel({
id: channel._id as Id<'channels'>,
name: trimmedName,
});
}
if (trimmedTopic !== originalTopic) {
await updateTopic({
id: channel._id as Id<'channels'>,
topic: trimmedTopic,
});
}
if (categoryId !== originalCategory) {
// `reorderChannels` is the one mutation that accepts a
// `categoryId` change — same path drag-and-drop already
// uses. Position 0 lands the channel at the top of the
// target category; the server reflows the rest.
await reorderChannels({
updates: [
{
id: channel._id as Id<'channels'>,
categoryId: (categoryId ?? undefined) as any,
position: 0,
},
],
});
}
setStatus({ type: 'success', message: 'Channel updated.' });
// Close after a short beat so the success message is visible.
setTimeout(() => onClose(), 500);
} catch (err: any) {
setStatus({
type: 'error',
message: err?.message || 'Failed to update channel.',
});
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!canModify) return;
setDeleting(true);
try {
await removeChannel({ id: channel._id as Id<'channels'> });
setConfirmDelete(false);
onClose();
} catch (err: any) {
setStatus({
type: 'error',
message: err?.message || 'Failed to delete channel.',
});
setDeleting(false);
}
};
return createPortal(
<div className={styles.page}>
<header className={styles.header}>
<button
type="button"
className={styles.headerButton}
onClick={onClose}
aria-label="Back"
>
<ArrowLeft size={22} weight="bold" />
</button>
<h1 className={styles.headerTitle}>Channel Settings</h1>
<button
type="button"
className={`${styles.headerAction} ${
canModify && isDirty && nameValid && topicValid && !saving
? styles.headerActionActive
: ''
}`}
onClick={handleSave}
disabled={
!canModify || !isDirty || !nameValid || !topicValid || saving
}
>
{saving ? 'Saving…' : 'Save'}
</button>
</header>
<main className={styles.body}>
{!canModify && (
<div className={styles.warning}>
You don't have permission to edit this channel.
</div>
)}
<label className={styles.fieldLabel}>Channel Name</label>
<input
type="text"
className={styles.input}
value={name}
maxLength={NAME_MAX}
onChange={(e) => setName(e.target.value)}
placeholder="channel-name"
disabled={!canModify}
/>
<label className={styles.fieldLabel}>Channel Topic</label>
<div className={styles.textareaWrap}>
<textarea
className={styles.textarea}
value={topic}
maxLength={TOPIC_MAX}
rows={6}
onChange={(e) => setTopic(e.target.value)}
placeholder="Let people know what this channel is about."
disabled={!canModify}
/>
<span className={styles.textareaCounter}>{topic.length}</span>
</div>
<button
type="button"
className={styles.pickerRow}
onClick={() => canModify && setShowCategoryPicker(true)}
disabled={!canModify}
>
<span className={styles.pickerIcon}>
<FolderPlus size={18} weight="fill" />
</span>
<span className={styles.pickerLabel}>Category</span>
<span className={styles.pickerValue}>{selectedCategoryName}</span>
<CaretRight size={16} weight="bold" className={styles.pickerChevron} />
</button>
{status && (
<p
className={`${styles.status} ${
status.type === 'success'
? styles.statusSuccess
: styles.statusError
}`}
>
{status.message}
</p>
)}
{canModify && (
<button
type="button"
className={styles.deleteButton}
onClick={() => setConfirmDelete(true)}
>
<Trash size={18} weight="fill" />
<span>Delete Channel</span>
</button>
)}
</main>
{/* Category picker sheet — tap a row to select, sheet auto-closes. */}
<BottomSheet
isOpen={showCategoryPicker}
onClose={() => setShowCategoryPicker(false)}
title="Select Category"
>
<div className={styles.pickerSheetBody}>
<button
type="button"
className={`${styles.pickerOption} ${categoryId === null ? styles.pickerOptionActive : ''}`}
onClick={() => {
setCategoryId(null);
setShowCategoryPicker(false);
}}
>
<span>Uncategorized</span>
</button>
{categories.map((cat) => (
<button
key={cat._id}
type="button"
className={`${styles.pickerOption} ${categoryId === cat._id ? styles.pickerOptionActive : ''}`}
onClick={() => {
setCategoryId(cat._id);
setShowCategoryPicker(false);
}}
>
<span>{cat.name}</span>
</button>
))}
</div>
</BottomSheet>
{/* Delete confirmation — small centered dialog. */}
{confirmDelete && (
<div
className={styles.confirmOverlay}
onClick={() => !deleting && setConfirmDelete(false)}
>
<div
className={styles.confirmDialog}
onClick={(e) => e.stopPropagation()}
>
<h2 className={styles.confirmTitle}>Delete Channel</h2>
<p className={styles.confirmText}>
Are you sure you want to delete <strong>#{channel.name}</strong>?
All messages in it will be permanently removed.
</p>
<div className={styles.confirmActions}>
<Button
variant="secondary"
size="sm"
onClick={() => setConfirmDelete(false)}
disabled={deleting}
>
Cancel
</Button>
<Button
variant="danger"
size="sm"
icon={<Trash size={14} />}
loading={deleting}
onClick={handleDelete}
>
Delete
</Button>
</div>
</div>
</div>
)}
</div>,
document.body,
);
}

View File

@@ -25,6 +25,7 @@
*/
import {
Smiley,
SmileyWink,
ArrowBendUpLeft,
ArrowBendUpRight,
PencilSimple,
@@ -68,6 +69,11 @@ interface MobileMessageActionsSheetProps {
onQuickReact: (emoji: string) => void;
/** Opens the full reaction picker. */
onAddReaction: () => void;
/**
* Opens the "who reacted" drawer. Only wired (and the Reactions
* action shown) when the target message has at least one reaction.
*/
onViewReactions?: () => void;
onReply: () => void;
onForward: () => void;
onEdit?: () => void;
@@ -91,6 +97,7 @@ export function MobileMessageActionsSheet({
hasContent,
onQuickReact,
onAddReaction,
onViewReactions,
onReply,
onForward,
onEdit,
@@ -154,12 +161,24 @@ export function MobileMessageActionsSheet({
</div>
{/* Group 1 — Add Reaction lives alone so it visually
echoes the quick reactions row above it. */}
echoes the quick reactions row above it. Reactions
(the "who reacted" viewer) shares this card when
the message has any existing reactions. */}
<div className={styles.actionList}>
<button type="button" className={styles.actionItem} onClick={wrap(onAddReaction)}>
<Smiley size={20} weight="fill" />
<span>Add Reaction</span>
</button>
{onViewReactions && (
<button
type="button"
className={styles.actionItem}
onClick={wrap(onViewReactions)}
>
<SmileyWink size={20} weight="fill" />
<span>Reactions</span>
</button>
)}
</div>
{/* Group 2 — message interaction actions (Reply /

View File

@@ -0,0 +1,131 @@
.body {
display: flex;
flex-direction: column;
gap: 0;
padding: 0 0 env(safe-area-inset-bottom, 0);
}
/* Horizontal chip strip — scrolls when the reactions overflow the
viewport, matching the partial-chip affordance in the mockup. */
.chipRow {
display: flex;
gap: 8px;
padding: 4px 16px 16px;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x proximity;
scrollbar-width: none;
-ms-overflow-style: none;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
}
.chipRow::-webkit-scrollbar {
display: none;
}
.chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
min-height: 40px;
background-color: transparent;
border: 1px solid transparent;
border-radius: 10px;
color: var(--text-primary, #fff);
font-size: 15px;
font-weight: 600;
cursor: pointer;
flex-shrink: 0;
scroll-snap-align: start;
transition: background-color 0.1s, border-color 0.1s;
-webkit-tap-highlight-color: transparent;
}
.chip:active {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.04));
}
.chipActive {
background-color: var(--background-secondary-alt);
border-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.08));
}
.chipCount {
color: var(--text-primary, #fff);
font-variant-numeric: tabular-nums;
}
.emojiGlyph {
display: block;
pointer-events: none;
}
.shortcodeRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px 12px;
border-top: 1px solid var(--background-header-secondary);
}
.shortcode {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--text-primary-muted, #a0a3a8);
text-transform: uppercase;
}
.optionsLink {
display: inline-flex;
align-items: center;
gap: 4px;
background: transparent;
border: none;
padding: 4px 0;
color: var(--text-secondary, #b5bac1);
font-size: 14px;
font-weight: 500;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.optionsLink:active {
color: var(--text-primary, #fff);
}
.userList {
display: flex;
flex-direction: column;
padding: 4px 8px 16px;
}
.userRow {
display: flex;
align-items: center;
gap: 14px;
padding: 10px 12px;
border-radius: 10px;
transition: background-color 0.1s;
-webkit-tap-highlight-color: transparent;
}
.userRow:active {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.04));
}
.userRowMe .userName {
color: var(--text-primary, #fff);
}
.userName {
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #fff);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,161 @@
/**
* MobileReactionsDrawer — mobile "who reacted" bottom sheet. Mirrors
* the desktop `ReactionsModal` but with a horizontally-scrollable emoji
* chip row on top and a full-width user list below, matching the
* Discord mobile pattern.
*
* Opens from the "Reactions" option in `MobileMessageActionsSheet`
* (shown only when the target message has at least one reaction).
*/
import { useState } from 'react';
import { CaretRight } from '@phosphor-icons/react';
import { Avatar, BottomSheet } from '@discord-clone/ui';
import type { ReactionUser } from './Messages';
import { TwemojiImg } from './TwemojiImg';
import {
reactionKeyToName,
resolveReactionKeyToUnicode,
} from '../../utils/emojiLookup';
import styles from './MobileReactionsDrawer.module.css';
export interface MobileReactionsDrawerReaction {
emoji: string;
count: number;
me: boolean;
users: ReactionUser[];
}
interface MobileReactionsDrawerProps {
isOpen: boolean;
onClose: () => void;
reactions: MobileReactionsDrawerReaction[];
myUserId: string | null;
customEmojiByName: Map<string, string>;
/**
* Called when the viewer taps "Remove Reaction" for the currently
* selected emoji. The parent wires this to `api.reactions.remove`;
* the drawer stays open because `reactions` is a live query — when
* the last reactor for that emoji leaves, `effectiveEmoji` falls
* back to the next available chip automatically.
*/
onRemoveOwnReaction: (emoji: string) => void;
}
export function MobileReactionsDrawer({
isOpen,
onClose,
reactions,
myUserId,
customEmojiByName,
onRemoveOwnReaction,
}: MobileReactionsDrawerProps) {
const [activeEmoji, setActiveEmoji] = useState<string | null>(null);
// Effective = activeEmoji if it still exists in the list, else the
// first chip. Guards against the user's own removal emptying the
// currently-selected chip.
const effectiveEmoji =
activeEmoji && reactions.some((r) => r.emoji === activeEmoji)
? activeEmoji
: (reactions[0]?.emoji ?? null);
const activeReaction = reactions.find((r) => r.emoji === effectiveEmoji);
const renderEmojiGlyph = (emojiKey: string, size: number) => {
const customUrl = /^[a-zA-Z0-9_]+$/.test(emojiKey)
? customEmojiByName.get(emojiKey.toLowerCase())
: undefined;
if (customUrl) {
return (
<img
src={customUrl}
alt={`:${emojiKey}:`}
width={size}
height={size}
className={styles.emojiGlyph}
draggable={false}
/>
);
}
return (
<TwemojiImg
emoji={resolveReactionKeyToUnicode(emojiKey)}
size={size}
/>
);
};
const shortcodeLabel = activeReaction
? `:${reactionKeyToName(activeReaction.emoji).toUpperCase()}:`
: '';
return (
<BottomSheet
isOpen={isOpen}
onClose={onClose}
disableDefaultHeader
showHandle
initialHeightSvh={50}
expandable
>
<div className={styles.body}>
{/* Emoji chip strip — horizontally scrollable. */}
<div className={styles.chipRow}>
{reactions.map((r) => {
const isActive = effectiveEmoji === r.emoji;
return (
<button
key={r.emoji}
type="button"
className={`${styles.chip} ${isActive ? styles.chipActive : ''}`}
onClick={() => setActiveEmoji(r.emoji)}
>
{renderEmojiGlyph(r.emoji, 22)}
<span className={styles.chipCount}>{r.count}</span>
</button>
);
})}
</div>
{/* Shortcode label + optional remove-own-reaction action. */}
{activeReaction && (
<div className={styles.shortcodeRow}>
<span className={styles.shortcode}>{shortcodeLabel}</span>
{activeReaction.me && (
<button
type="button"
className={styles.optionsLink}
onClick={() => onRemoveOwnReaction(activeReaction.emoji)}
>
Remove Reaction
<CaretRight size={14} weight="bold" />
</button>
)}
</div>
)}
{/* User list for the currently selected emoji. */}
<div className={styles.userList}>
{activeReaction?.users.map((u) => {
const isMe = myUserId !== null && u.userId === myUserId;
return (
<div
key={u.userId}
className={`${styles.userRow} ${isMe ? styles.userRowMe : ''}`}
>
<Avatar
src={undefined}
fallback={u.displayName || u.username}
size={40}
/>
<span className={styles.userName}>
{u.displayName || u.username}
</span>
</div>
);
})}
</div>
</div>
</BottomSheet>
);
}

View File

@@ -12,9 +12,12 @@ import { CreateChannelModal } from './CreateChannelModal';
import { CreateCategoryModal } from './CategorySettingsModal';
import { InviteModal } from '../modals/InviteModal';
import { CreateServerModal } from '../modals/CreateServerModal';
import { PiPOverlay } from '../voice/PiPOverlay';
import { RecordingRecoveryModal } from '../voice/RecordingRecoveryModal';
import { ChannelSettingsModal } from '../channel/ChannelSettingsModal';
import { MobileChannelSettingsPage } from '../channel/MobileChannelSettingsPage';
import { MobileCreateChannelPage } from './MobileCreateChannelPage';
import { MobileCreateCategoryPage } from './MobileCreateCategoryPage';
import { useIsMobile } from '../../hooks/useIsMobile';
import { UpdateBanner } from './UpdateBanner';
/**
@@ -42,6 +45,7 @@ export function AppLayout() {
const [serverSettingsOpen, setServerSettingsOpen] = useState(false);
const [createServerOpen, setCreateServerOpen] = useState(false);
const [channelSettingsId, setChannelSettingsId] = useState<string | null>(null);
const isMobile = useIsMobile();
// Keybind / cross-component nav bridge
useEffect(() => {
@@ -222,25 +226,51 @@ export function AppLayout() {
isOpen={serverSettingsOpen}
onClose={() => setServerSettingsOpen(false)}
/>
<CreateChannelModal
isOpen={createChannelOpen}
onClose={() => setCreateChannelOpen(false)}
/>
<CreateCategoryModal
isOpen={createCategoryOpen}
onClose={() => setCreateCategoryOpen(false)}
/>
{/* Create Channel / Category / Channel Settings all have
mobile fullscreen variants that take over from the
desktop modals under the mobile breakpoint. The other
component stays unmounted so there's no duplicate
portaled chrome or back-stack entries. */}
{isMobile ? (
<MobileCreateChannelPage
isOpen={createChannelOpen}
onClose={() => setCreateChannelOpen(false)}
/>
) : (
<CreateChannelModal
isOpen={createChannelOpen}
onClose={() => setCreateChannelOpen(false)}
/>
)}
{isMobile ? (
<MobileCreateCategoryPage
isOpen={createCategoryOpen}
onClose={() => setCreateCategoryOpen(false)}
/>
) : (
<CreateCategoryModal
isOpen={createCategoryOpen}
onClose={() => setCreateCategoryOpen(false)}
/>
)}
<InviteModal isOpen={inviteOpen} onClose={() => setInviteOpen(false)} />
<CreateServerModal
isOpen={createServerOpen}
onClose={() => setCreateServerOpen(false)}
/>
<ChannelSettingsModal
isOpen={!!channelSettingsId}
onClose={() => setChannelSettingsId(null)}
channelId={channelSettingsId}
/>
<PiPOverlay />
{isMobile ? (
<MobileChannelSettingsPage
isOpen={!!channelSettingsId}
onClose={() => setChannelSettingsId(null)}
channelId={channelSettingsId}
/>
) : (
<ChannelSettingsModal
isOpen={!!channelSettingsId}
onClose={() => setChannelSettingsId(null)}
channelId={channelSettingsId}
/>
)}
<RecordingRecoveryModal />
<UpdateBanner />
</KeybindProvider>

View File

@@ -1,4 +1,13 @@
import { FolderPlus, Gear, Hash, PencilSimple, SignOut, Trash } from '@phosphor-icons/react';
import {
FolderPlus,
Gear,
Hash,
PencilSimple,
PushPin,
PushPinSlash,
SignOut,
Trash,
} from '@phosphor-icons/react';
import { useEffect, useRef } from 'react';
import styles from './ChannelListContextMenu.module.css';
@@ -25,6 +34,15 @@ interface ChannelListContextMenuProps {
* entry so the user can always nuke a phantom from their sidebar.
*/
onLeaveChannel?: () => void;
/**
* Toggles the pinned-channel state for the right-clicked channel.
* Pinning is a per-user preference stored in `userPreferences` —
* pinned channels stay visible even when their category is
* collapsed. The menu row toggles between "Pin Channel" and
* "Unpin Channel" based on `isPinnedChannel`.
*/
onTogglePinChannel?: () => void;
isPinnedChannel?: boolean;
onClose: () => void;
}
@@ -42,6 +60,8 @@ export function ChannelListContextMenu({
onRenameChannel,
onDeleteChannel,
onLeaveChannel,
onTogglePinChannel,
isPinnedChannel = false,
onClose,
}: ChannelListContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
@@ -74,6 +94,18 @@ export function ChannelListContextMenu({
<div className={styles.menu} ref={ref} style={style}>
{channelId ? (
<>
{onTogglePinChannel && (
<button className={styles.menuItem} onClick={onTogglePinChannel}>
{isPinnedChannel ? (
<PushPinSlash size={18} />
) : (
<PushPin size={18} weight="fill" />
)}
<span>
{isPinnedChannel ? 'Unpin Channel' : 'Pin Channel'}
</span>
</button>
)}
{onRenameChannel && (
<button className={styles.menuItem} onClick={onRenameChannel}>
<PencilSimple size={18} />

View File

@@ -221,6 +221,16 @@
color: var(--text-primary) !important;
}
/* Voice channels with someone currently connected tint the speaker
glyph green — matches the "active voice" hint users see in Discord.
Wins over the selected-channel rule via specificity of both class
selectors; the selected-voice case still sees the green so the
signal isn't lost when the user is the one in the channel. */
.channelIconVoiceActive,
.channelSelected .channelIconVoiceActive {
color: hsl(138.837 calc(1 * 36.441%) 53.725% / 1) !important;
}
.channelIcon {
width: 1.25rem;
height: 1.25rem;
@@ -303,6 +313,27 @@
On hover or select, the unread/mention badges are hidden so the gear
takes their place — same pattern Discord uses.
*/
/* Drop-target highlight when a voice user is being dragged onto
this channel. The row itself doesn't border well (channel buttons
already have their own hover/selected backgrounds), so tint the
inner `.channelItem` via the parent-state selector below. */
.channelItemDropTarget .channelItem {
background-color: color-mix(
in srgb,
hsl(138.837 calc(1 * 36.441%) 53.725% / 1) 22%,
transparent
);
box-shadow: inset 0 0 0 2px hsl(138.837 calc(1 * 36.441%) 53.725% / 0.9);
}
.voiceUser[draggable="true"] {
cursor: grab;
}
.voiceUser[draggable="true"]:active {
cursor: grabbing;
}
.channelItemWrapper {
position: relative;
min-width: 0;
@@ -333,16 +364,34 @@
background-color: color-mix(in srgb, var(--text-primary) 10%, transparent);
}
.channelItemWrapper[data-selected="true"] .channelSettingsGear {
display: flex;
color: var(--text-primary);
}
.channelItemWrapper[data-selected="true"] .channelItem {
padding-right: 2.25rem;
/* Voice-channel timer — shown by default for the viewer's own voice
channel, tucked into the same right-edge slot the gear lives in.
Uses the same green as the voice-active icon tint so the "you're
in voice" signal is visually consistent. */
.channelVoiceTimer {
display: inline-flex;
align-items: center;
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
padding: 0 2px;
color: hsl(138.837 calc(1 * 36.441%) 53.725% / 1);
font-size: 0.75rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
pointer-events: none;
z-index: 2;
}
/* Desktop: hover replaces the timer with the gear.
Mobile: the timer is always hidden (gear is already hidden, long-
press drawer takes over). */
@media (hover: hover) and (pointer: fine) {
.channelItemWrapper:hover .channelVoiceTimer {
display: none;
}
.channelItemWrapper:hover .channelSettingsGear {
display: flex;
}
@@ -357,11 +406,34 @@
}
}
/* Whenever the timer is rendered, reserve room for it (and for the
gear that takes its spot on hover) so the channel name doesn't
overlap the right-edge chip. */
.channelItemWrapper:has(.channelVoiceTimer) .channelItem {
padding-right: 4rem;
}
.channelItemWrapper[data-selected="true"] .mentionBadge,
.channelItemWrapper[data-selected="true"] .unreadBadge {
display: none;
}
/* Mobile replaces the hover-gear affordance with a long-press
drawer (MobileChannelActionsSheet). Hide the gear and the voice
timer on the mobile breakpoint, and drop the extra right-padding
that was reserved for them. */
@media (max-width: 768px) {
.channelItemWrapper .channelSettingsGear,
.channelItemWrapper[data-selected="true"] .channelSettingsGear,
.channelItemWrapper .channelVoiceTimer {
display: none !important;
}
.channelItemWrapper:has(.channelVoiceTimer) .channelItem,
.channelItemWrapper[data-selected="true"] .channelItem {
padding-right: 0.75rem;
}
}
/* ── Voice Channel Styles ────────────────────────────────────────────── */
.voiceUsers {

View File

@@ -23,6 +23,65 @@ import {
SpeakerHigh,
SpeakerSlash,
} from '@phosphor-icons/react';
// Inline the pinned-channel glyphs so `currentColor` inherits from
// the row's text color the same way Phosphor's <Hash /> / <SpeakerHigh />
// do. An <img src=".../hashtag-pinned.svg"> wouldn't pick up
// currentColor, and declaring them here keeps the assets self-contained
// for their one caller.
function HashtagPinnedIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={20}
height={20}
fill="currentColor"
viewBox="0 0 256 256"
className={className}
aria-hidden="true"
>
<defs>
<mask id="hashtag-pinned-mask">
<path fill="#fff" d="M0 0h256v256H0z" />
<circle cx="192" cy="64" r="60" fill="#000" />
</mask>
</defs>
<path
mask="url(#hashtag-pinned-mask)"
d="M224 84h-43.8l7.61-41.85a12 12 0 0 0-23.62-4.3L155.8 84h-39.6l7.61-41.85a12 12 0 1 0-23.62-4.3L91.8 84H48a12 12 0 0 0 0 24h39.44l-7.27 40H32a12 12 0 0 0 0 24h43.8l-7.61 41.85a12 12 0 0 0 9.66 14A11.4 11.4 0 0 0 80 228a12 12 0 0 0 11.8-9.86l8.4-46.14h39.6l-7.61 41.85a12 12 0 0 0 9.66 14 11.4 11.4 0 0 0 2.16.2 12 12 0 0 0 11.8-9.86L164.2 172H208a12 12 0 0 0 0-24h-39.44l7.27-40H224a12 12 0 0 0 0-24m-79.83 64h-39.61l7.27-40h39.61Z"
/>
<path d="M245.665 52 218.93 78.825c2.28 6.335 3.225 16.945-6.595 30A7.97 7.97 0 0 1 206.5 112h-.565a8 8 0 0 1-5.66-2.345L176.145 85.5l-21.315 21.33a4.002 4.002 0 0 1-5.66-5.66l21.33-21.315-24.15-24.15a8 8 0 0 1 .65-11.89c12.71-10.255 24.875-8.24 30.2-6.57L204 10.35a8 8 0 0 1 11.315 0l30.345 30.34a8 8 0 0 1 .005 11.31" />
</svg>
);
}
function VoicePinnedIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={20}
height={20}
fill="currentColor"
viewBox="0 0 256 256"
className={className}
aria-hidden="true"
>
<defs>
<mask id="voice-pinned-mask">
<rect width="256" height="256" fill="white" />
<circle cx="192" cy="64" r="60" fill="black" />
</mask>
</defs>
<path
mask="url(#voice-pinned-mask)"
d="M160,32.25V223.69a8.29,8.29,0,0,1-3.91,7.18,8,8,0,0,1-9-.56l-65.57-51A4,4,0,0,1,80,176.16V79.84a4,4,0,0,1,1.55-3.15l65.57-51a8,8,0,0,1,10,.16A8.27,8.27,0,0,1,160,32.25ZM60,80H32A16,16,0,0,0,16,96v64a16,16,0,0,0,16,16H60a4,4,0,0,0,4-4V84A4,4,0,0,0,60,80Zm126.77,20.84a8,8,0,0,0-.72,11.3,24,24,0,0,1,0,31.72,8,8,0,1,0,12,10.58,40,40,0,0,0,0-52.88A8,8,0,0,0,186.74,100.84Zm40.89-26.17a8,8,0,1,0-11.92,10.66,64,64,0,0,1,0,85.34,8,8,0,1,0,11.92,10.66,80,80,0,0,0,0-106.66Z"
/>
<g transform="translate(128, 0) scale(0.5)">
<path d="M235.33,104l-53.47,53.65c4.56,12.67,6.45,33.89-13.19,60A15.93,15.93,0,0,1,157,224c-.38,0-.75,0-1.13,0a16,16,0,0,1-11.32-4.69L96.29,171,53.66,213.66a8,8,0,0,1-11.32-11.32L85,159.71l-48.3-48.3A16,16,0,0,1,38,87.63c25.42-20.51,49.75-16.48,60.4-13.14L152,20.7a16,16,0,0,1,22.63,0l60.69,60.68A16,16,0,0,1,235.33,104Z" />
</g>
</svg>
);
}
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Avatar } from '@discord-clone/ui';
@@ -33,12 +92,17 @@ import { getUserPref, setUserPref } from '../../utils/userPreferences';
import { GuildHeaderDropdown } from './GuildHeaderDropdown';
import { ChannelListContextMenu } from './ChannelListContextMenu';
import { MobileServerActionsSheet } from './MobileServerActionsSheet';
import { MobileChannelActionsSheet } from '../channel/MobileChannelActionsSheet';
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';
// Custom MIME type for the voice-user drag payload. Picked so it
// doesn't collide with generic text/plain drops (URL drags, etc).
const VOICE_USER_DND_MIME = 'application/x-brycord-voice-user';
type VoiceParticipant = {
userId: string;
username: string;
@@ -46,6 +110,12 @@ type VoiceParticipant = {
isMuted?: boolean;
isDeafened?: boolean;
isScreenSharing?: boolean;
/**
* Unix ms from the voiceState row's `_creationTime` — populated by
* `api.voiceState.getAll`. Used to render the "time in channel"
* indicator for the viewer's own voice channel.
*/
joinedAt?: number;
};
type ChannelDoc = {
@@ -137,6 +207,36 @@ export function GuildNavbar() {
const deleteChannel = useMutation(api.channels.remove);
const renameCategory = useMutation(api.categories.rename);
const deleteCategory = useMutation(api.categories.remove);
const markChannelRead = useMutation(api.readState.markRead);
const moveVoiceUser = useMutation(api.voiceState.moveUser);
// Permission check for the mobile "Edit Channel" action — the
// server enforces `manage_channels` anyway, but hiding the row
// in the drawer keeps the UX honest for viewers who can't act.
// `move_members` gates the drag-to-move-voice-user gesture below.
const myPerms = useQuery(
api.roles.getMyPermissions,
userId ? { userId: userId as any } : 'skip',
);
const canManageChannels = !!myPerms?.manage_channels;
const canMoveMembers = !!myPerms?.move_members;
const handleMoveVoiceUser = (
sourceUserId: string,
targetChannelId: string,
) => {
if (!userId) return;
// Server double-checks move_members and no-ops if the target
// is already in the destination channel, so a 403 here just
// means the permission changed between render and drop.
void moveVoiceUser({
actorUserId: userId as any,
targetUserId: sourceUserId as any,
targetChannelId: targetChannelId as any,
}).catch((err) =>
console.error('Failed to move voice user:', err),
);
};
type ContextMenuState =
| {
@@ -198,6 +298,24 @@ export function GuildNavbar() {
const saved = getUserPref(userId, 'collapsedCategories', null);
return Array.isArray(saved) ? new Set(saved) : new Set();
});
// Per-user pinned channel ids. Stored like `collapsedCategories`
// (localStorage + optional disk via the platform settings API), so
// pins survive refresh and sync across tabs on the same device.
// Pinned channels remain rendered even when their parent category
// is collapsed — that's the whole point of the feature.
const [pinnedChannels, setPinnedChannels] = useState<Set<string>>(() => {
const saved = getUserPref(userId, 'pinnedChannels', null);
return Array.isArray(saved) ? new Set(saved) : new Set();
});
const togglePinnedChannel = (channelId: string) => {
setPinnedChannels((prev) => {
const next = new Set(prev);
if (next.has(channelId)) next.delete(channelId);
else next.add(channelId);
setUserPref(userId, 'pinnedChannels', [...next], platform?.settings);
return next;
});
};
const headerRef = useRef<HTMLButtonElement>(null);
const [headerDropdownRect, setHeaderDropdownRect] = useState<DOMRect | null>(null);
const [mobileServerSheetOpen, setMobileServerSheetOpen] = useState(false);
@@ -205,6 +323,12 @@ export function GuildNavbar() {
{ x: number; y: number; userId: string; username: string } | null
>(null);
const [voiceProfileFor, setVoiceProfileFor] = useState<string | null>(null);
// Mobile long-press on a channel row → actions sheet (Mark As
// Read / Edit Channel). Tracks channel id + name so the sheet
// header can show "#channel-name".
const [mobileChannelActions, setMobileChannelActions] = useState<
{ channelId: string; channelName: string } | null
>(null);
useBackHandler(mobileServerSheetOpen, () => setMobileServerSheetOpen(false));
useBackHandler(!!voiceMenu, () => setVoiceMenu(null));
useBackHandler(!!voiceProfileFor, () => setVoiceProfileFor(null));
@@ -446,8 +570,29 @@ export function GuildNavbar() {
unreadCount={unread?.unreadCount ?? 0}
mentionCount={unread?.mentionCount ?? 0}
participants={participants}
isPinned={pinnedChannels.has(ch._id)}
viewerJoinedAt={
!isMobile
? (participants.find(
(p) => p.userId === userId,
)?.joinedAt ?? null)
: null
}
canMoveVoiceUsers={canMoveMembers && !isMobile}
onMoveVoiceUserDrop={(sourceUserId) =>
handleMoveVoiceUser(sourceUserId, ch._id)
}
onClick={() => handleChannelClick(ch._id)}
onOpenSettings={() => handleOpenChannelSettings(ch._id)}
onLongPress={
isMobile
? () =>
setMobileChannelActions({
channelId: ch._id,
channelName: ch.name,
})
: undefined
}
onVoiceUserContextMenu={(p, e) => {
setVoiceMenu({
x: e.clientX,
@@ -486,11 +631,16 @@ export function GuildNavbar() {
{categories.map((category) => {
const catChannels = byCategory.get(category._id) ?? [];
const isCollapsed = collapsedCategories.has(category._id);
// Collapsed categories still show the currently
// Collapsed categories still show (a) the currently
// selected channel row so the user never loses
// their place. Matches the new UI.
// their place, and (b) any channels the user has
// pinned — pinning overrides collapse.
const visibleChannels = isCollapsed
? catChannels.filter((c) => c._id === selectedChannelId)
? catChannels.filter(
(c) =>
c._id === selectedChannelId ||
pinnedChannels.has(c._id),
)
: catChannels;
return (
<SortableCategory
@@ -535,10 +685,33 @@ export function GuildNavbar() {
unreadCount={unread?.unreadCount ?? 0}
mentionCount={unread?.mentionCount ?? 0}
participants={participants}
isPinned={pinnedChannels.has(ch._id)}
viewerJoinedAt={
!isMobile
? (participants.find(
(p) => p.userId === userId,
)?.joinedAt ?? null)
: null
}
canMoveVoiceUsers={
canMoveMembers && !isMobile
}
onMoveVoiceUserDrop={(sourceUserId) =>
handleMoveVoiceUser(sourceUserId, ch._id)
}
onClick={() => handleChannelClick(ch._id)}
onOpenSettings={() =>
handleOpenChannelSettings(ch._id)
}
onLongPress={
isMobile
? () =>
setMobileChannelActions({
channelId: ch._id,
channelName: ch.name,
})
: undefined
}
onVoiceUserContextMenu={(p, e) => {
setVoiceMenu({
x: e.clientX,
@@ -616,6 +789,19 @@ export function GuildNavbar() {
onCreateCategory={() =>
window.dispatchEvent(new CustomEvent('brycord:open-create-category'))
}
onTogglePinChannel={
contextMenu.kind === 'channel'
? () => {
togglePinnedChannel(contextMenu.channelId);
setContextMenu(null);
}
: undefined
}
isPinnedChannel={
contextMenu.kind === 'channel'
? pinnedChannels.has(contextMenu.channelId)
: false
}
onRenameChannel={
contextMenu.kind === 'channel'
? () => {
@@ -679,6 +865,36 @@ export function GuildNavbar() {
onClose={() => setContextMenu(null)}
/>
)}
{/* Mobile long-press drawer for channel rows. Only renders on
mobile; desktop users still get the hover-gear affordance. */}
<MobileChannelActionsSheet
isOpen={!!mobileChannelActions}
onClose={() => setMobileChannelActions(null)}
channelName={mobileChannelActions?.channelName ?? ''}
canEdit={canManageChannels}
isPinned={
mobileChannelActions
? pinnedChannels.has(mobileChannelActions.channelId)
: false
}
onMarkAsRead={() => {
if (!mobileChannelActions || !userId) return;
void markChannelRead({
userId: userId as any,
channelId: mobileChannelActions.channelId as any,
lastReadTimestamp: Date.now(),
});
}}
onTogglePin={() => {
if (!mobileChannelActions) return;
togglePinnedChannel(mobileChannelActions.channelId);
}}
onEditChannel={() => {
if (!mobileChannelActions) return;
handleOpenChannelSettings(mobileChannelActions.channelId);
}}
/>
</div>
);
}
@@ -691,8 +907,37 @@ interface SortableChannelRowProps {
unreadCount?: number;
mentionCount?: number;
participants?: VoiceParticipant[];
/**
* When true, renders a small pin glyph next to the channel name.
* Pinned channels also stay visible even when their category is
* collapsed — that filter is applied in the parent, so this flag
* only drives the visual affordance.
*/
isPinned?: boolean;
/**
* Unix ms when the viewer joined this voice channel. Set only on
* the channel the viewer is currently in; the row renders a live
* H:MM:SS / MM:SS counter at the gear's position when provided.
* Mobile callers pass `undefined` so the counter never shows there.
*/
viewerJoinedAt?: number | null;
/**
* When true, voice participants in this row are draggable and the
* row accepts drops of other voice users. Gated on `move_members`
* in the parent; undefined / false disables the whole gesture.
*/
canMoveVoiceUsers?: boolean;
/** Drop handler — receives the dragged user's id. */
onMoveVoiceUserDrop?: (sourceUserId: string) => void;
onClick: () => void;
onOpenSettings: () => void;
/**
* Mobile long-press handler — wire to open the
* `MobileChannelActionsSheet`. The row owns the 450 ms timer and
* cancels on finger move / up so a scroll-gesture still works.
* Omit on desktop to skip touch wiring entirely.
*/
onLongPress?: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
onVoiceUserContextMenu?: (
participant: VoiceParticipant,
@@ -708,8 +953,13 @@ function SortableChannelRow({
unreadCount = 0,
mentionCount = 0,
participants = [],
isPinned = false,
viewerJoinedAt = null,
canMoveVoiceUsers = false,
onMoveVoiceUserDrop,
onClick,
onOpenSettings,
onLongPress,
onContextMenu,
onVoiceUserContextMenu,
}: SortableChannelRowProps) {
@@ -724,28 +974,140 @@ function SortableChannelRow({
const hasUnread = unreadCount > 0 && !selected;
const hasMention = mentionCount > 0;
const showParticipants = isVoice && participants.length > 0;
// Live clock for the "time in channel" indicator. `useState` driven
// by a 1-second interval that only runs when `viewerJoinedAt` is
// set (i.e. the viewer is actually in this channel) — idle rows
// pay no re-render cost. The formatter collapses hours when the
// session is under an hour so short sessions read "12:34" instead
// of "0:12:34".
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!viewerJoinedAt) return;
setNow(Date.now());
const id = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, [viewerJoinedAt]);
const formatDuration = (elapsedMs: number): string => {
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
const pad = (n: number) => n.toString().padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
};
// 450 ms long-press → open the mobile channel actions sheet. Same
// timing as the message long-press so the muscle memory carries
// over. Finger movement / lift cancels before the timer fires so
// a scroll-gesture through the sidebar still works.
const longPressTimerRef = useRef<number | null>(null);
const firedLongPressRef = useRef(false);
// Native HTML5 DnD state for "drag a voice user into this channel"
// — only tracked on voice rows that are accepting drops.
const [isDropTarget, setIsDropTarget] = useState(false);
const acceptsVoiceDrop = isVoice && canMoveVoiceUsers && !!onMoveVoiceUserDrop;
const handleWrapperDragOver = (e: React.DragEvent) => {
if (!acceptsVoiceDrop) return;
if (!e.dataTransfer.types.includes(VOICE_USER_DND_MIME)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (!isDropTarget) setIsDropTarget(true);
};
const handleWrapperDragLeave = (e: React.DragEvent) => {
if (!acceptsVoiceDrop) return;
// `dragleave` fires on children too — only clear when leaving
// the wrapper itself. `relatedTarget` is the element the
// pointer entered; if it's still inside `currentTarget`, we're
// still hovering the drop zone.
const next = e.relatedTarget as Node | null;
if (next && e.currentTarget.contains(next)) return;
setIsDropTarget(false);
};
const handleWrapperDrop = (e: React.DragEvent) => {
if (!acceptsVoiceDrop) return;
const payload = e.dataTransfer.getData(VOICE_USER_DND_MIME);
if (!payload) return;
e.preventDefault();
setIsDropTarget(false);
onMoveVoiceUserDrop?.(payload);
};
const startLongPress = () => {
if (!onLongPress) return;
firedLongPressRef.current = false;
if (longPressTimerRef.current !== null) {
window.clearTimeout(longPressTimerRef.current);
}
longPressTimerRef.current = window.setTimeout(() => {
firedLongPressRef.current = true;
onLongPress();
}, 450);
};
const cancelLongPress = () => {
if (longPressTimerRef.current !== null) {
window.clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
};
return (
<>
<div
ref={setNodeRef}
style={style}
className={`${styles.channelItemWrapper} ${hasUnread ? styles.channelItemUnread : ''}`}
className={`${styles.channelItemWrapper} ${hasUnread ? styles.channelItemUnread : ''} ${isDropTarget ? styles.channelItemDropTarget : ''}`}
data-selected={selected ? 'true' : undefined}
data-unread={hasUnread ? 'true' : undefined}
data-drop-target={isDropTarget ? 'true' : undefined}
onContextMenu={onContextMenu}
onTouchStart={onLongPress ? startLongPress : undefined}
onTouchEnd={onLongPress ? cancelLongPress : undefined}
onTouchMove={onLongPress ? cancelLongPress : undefined}
onTouchCancel={onLongPress ? cancelLongPress : undefined}
onDragOver={acceptsVoiceDrop ? handleWrapperDragOver : undefined}
onDragLeave={acceptsVoiceDrop ? handleWrapperDragLeave : undefined}
onDrop={acceptsVoiceDrop ? handleWrapperDrop : undefined}
{...attributes}
{...listeners}
>
<button
type="button"
className={`${styles.channelItem} ${selected ? styles.channelSelected : ''}`}
onClick={onClick}
onClick={(e) => {
// Swallow the click that would otherwise fire
// immediately after a long-press-triggered sheet
// opens and navigate the user into the channel.
if (firedLongPressRef.current) {
firedLongPressRef.current = false;
e.preventDefault();
return;
}
onClick();
}}
>
{isVoice ? (
<SpeakerHigh size={20} className={styles.channelIcon} />
) : (
<Hash size={20} weight="bold" className={styles.channelIcon} />
)}
{(() => {
// Green-tint the voice glyph whenever anyone is
// currently connected — makes a live voice channel
// pop in the sidebar at a glance.
const voiceActive = isVoice && participants.length > 0;
const iconClass = `${styles.channelIcon} ${
voiceActive ? styles.channelIconVoiceActive : ''
}`;
if (isVoice) {
return isPinned ? (
<VoicePinnedIcon className={iconClass} />
) : (
<SpeakerHigh size={20} className={iconClass} />
);
}
return isPinned ? (
<HashtagPinnedIcon className={iconClass} />
) : (
<Hash size={20} weight="bold" className={iconClass} />
);
})()}
<span
className={`${styles.channelName} ${hasUnread ? styles.channelUnread : ''}`}
>
@@ -757,6 +1119,15 @@ function SortableChannelRow({
</span>
)}
</button>
{viewerJoinedAt && (
<span
className={styles.channelVoiceTimer}
aria-label="Time in voice channel"
title="Time in voice channel"
>
{formatDuration(now - viewerJoinedAt)}
</span>
)}
<button
type="button"
className={styles.channelSettingsGear}
@@ -774,12 +1145,31 @@ function SortableChannelRow({
<div
className={styles.voiceUsers}
onPointerDown={(e) => e.stopPropagation()}
// Mirror the wrapper drop handlers so users can drop
// directly onto the participant list too — not just
// the channel header row.
onDragOver={acceptsVoiceDrop ? handleWrapperDragOver : undefined}
onDragLeave={acceptsVoiceDrop ? handleWrapperDragLeave : undefined}
onDrop={acceptsVoiceDrop ? handleWrapperDrop : undefined}
>
{participants.map((p) => (
<div
key={p.userId}
className={styles.voiceUser}
title={p.username}
draggable={canMoveVoiceUsers}
onDragStart={
canMoveVoiceUsers
? (e) => {
e.stopPropagation();
e.dataTransfer.setData(
VOICE_USER_DND_MIME,
p.userId,
);
e.dataTransfer.effectAllowed = 'move';
}
: undefined
}
onContextMenu={(e) => {
if (!onVoiceUserContextMenu) return;
e.preventDefault();

View File

@@ -0,0 +1,101 @@
/**
* MobileCreateCategoryPage — fullscreen "Create Category" page for
* mobile. Replaces the desktop `CreateCategoryModal` inside
* `AppLayout` when `useIsMobile()` is true.
*/
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useMutation } from 'convex/react';
import { X } from '@phosphor-icons/react';
import { api } from '../../../../../convex/_generated/api';
import { useBackHandler } from '../../hooks/useBackHandler';
import styles from './MobileCreateChannelPage.module.css';
const NAME_MAX = 100;
interface MobileCreateCategoryPageProps {
isOpen: boolean;
onClose: () => void;
}
export function MobileCreateCategoryPage({
isOpen,
onClose,
}: MobileCreateCategoryPageProps) {
const createCategory = useMutation(api.categories.create);
const [name, setName] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
setName('');
setSaving(false);
setError(null);
}
}, [isOpen]);
useBackHandler(isOpen, onClose);
if (!isOpen) return null;
const trimmed = name.trim();
const canCreate = trimmed.length > 0 && trimmed.length <= NAME_MAX && !saving;
const handleCreate = async () => {
if (!canCreate) return;
setSaving(true);
setError(null);
try {
await createCategory({ name: trimmed });
onClose();
} catch (err: any) {
setError(err?.message || 'Failed to create category.');
} finally {
setSaving(false);
}
};
return createPortal(
<div className={styles.page}>
<header className={styles.header}>
<button
type="button"
className={styles.headerButton}
onClick={onClose}
aria-label="Close"
>
<X size={22} weight="bold" />
</button>
<h1 className={styles.headerTitle}>Create Category</h1>
<button
type="button"
className={`${styles.headerAction} ${
canCreate ? styles.headerActionActive : ''
}`}
onClick={handleCreate}
disabled={!canCreate}
>
{saving ? 'Creating…' : 'Create'}
</button>
</header>
<main className={styles.body}>
<label className={styles.fieldLabel}>Category Name</label>
<input
type="text"
className={styles.input}
value={name}
maxLength={NAME_MAX}
onChange={(e) => setName(e.target.value)}
placeholder="New Category"
autoFocus
/>
{error && <p className={styles.error}>{error}</p>}
</main>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,203 @@
.page {
position: fixed;
inset: 0;
z-index: var(--z-index-modal, 10000);
display: flex;
flex-direction: column;
background-color: var(--background-primary, #1e1f22);
color: var(--text-primary, #fff);
padding-top: env(safe-area-inset-top, 0);
}
.header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 8px;
border-bottom: 1px solid var(--background-header-secondary);
flex-shrink: 0;
}
.headerButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: transparent;
border: none;
color: var(--text-primary, #fff);
cursor: pointer;
border-radius: 8px;
-webkit-tap-highlight-color: transparent;
}
.headerButton:active {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.06));
}
.headerTitle {
flex: 1;
margin: 0;
font-size: 18px;
font-weight: 700;
text-align: center;
color: var(--text-primary, #fff);
}
.headerAction {
padding: 8px 16px;
background: transparent;
border: none;
font-size: 16px;
font-weight: 600;
color: var(--text-primary-muted, #6d6f78);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.headerActionActive {
color: var(--brand-primary, #5865f2);
}
.headerAction:disabled {
cursor: default;
}
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 20px 16px 40px;
display: flex;
flex-direction: column;
}
.fieldLabel {
font-size: 13px;
color: var(--text-primary-muted, #a0a3a8);
margin-bottom: 8px;
}
.input {
width: 100%;
padding: 16px;
border-radius: 14px;
border: none;
background-color: var(--background-tertiary, #1e1f22);
color: var(--text-primary, #fff);
font-size: 15px;
outline: none;
}
.input:focus {
background-color: var(--background-secondary, #2b2d31);
}
.sectionHeading {
margin: 24px 0 12px;
font-size: 15px;
font-weight: 700;
color: var(--text-primary, #fff);
}
.typeList {
display: flex;
flex-direction: column;
gap: 0;
border-radius: 14px;
overflow: hidden;
background-color: var(--background-tertiary, #1e1f22);
}
.typeOption {
position: relative;
display: flex;
align-items: center;
gap: 14px;
width: 100%;
padding: 14px 16px;
border: none;
background-color: transparent;
color: var(--text-primary, #fff);
font: inherit;
text-align: left;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.typeOption:active {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.04));
}
.typeOption:not(:last-child)::after {
content: '';
position: absolute;
left: 1rem;
right: 1rem;
bottom: 0;
height: 1px;
background-color: var(--background-header-secondary);
pointer-events: none;
}
.typeOptionSelected {
/* subtle selection chip — matches the radio fill */
}
.typeIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
color: var(--text-primary, #fff);
flex-shrink: 0;
}
.typeMeta {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
gap: 2px;
}
.typeLabel {
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #fff);
}
.typeDescription {
font-size: 13px;
color: var(--text-primary-muted, #a0a3a8);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.radio {
flex-shrink: 0;
width: 22px;
height: 22px;
border: 2px solid var(--text-primary-muted, #6d6f78);
border-radius: 50%;
background-color: transparent;
transition: background-color 0.1s, border-color 0.1s;
}
.radioSelected {
border-color: var(--brand-primary, #5865f2);
background-color: var(--brand-primary, #5865f2);
box-shadow: inset 0 0 0 4px var(--background-tertiary, #1e1f22);
}
.error {
margin-top: 16px;
padding: 12px 14px;
border-radius: 10px;
background-color: color-mix(in srgb, var(--status-danger, #ef4444) 20%, transparent);
color: var(--status-danger, #ef4444);
font-size: 14px;
}

View File

@@ -0,0 +1,176 @@
/**
* MobileCreateChannelPage — fullscreen "Create Channel" page for
* mobile. Replaces the desktop `CreateChannelModal` inside
* `AppLayout` when `useIsMobile()` is true.
*
* Layout (matches the design mock):
* ┌─ ✕ Create Channel Create ┐
* │ │
* │ Channel Name │
* │ [ new-channel ] │
* │ │
* │ Channel Type │
* │ ◉ # Text │
* │ ○ 🔊 Voice │
* │ │
* └─────────────────────────────────┘
*
* Only Text and Voice are wired — the backend's channel `type` field
* is schemaless enough to accept anything, but the rest of the app
* (channel list rendering, voice context, read-state, etc.) only
* knows those two types. Forum is a potential future addition.
*/
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useMutation } from 'convex/react';
import { Hash, SpeakerHigh, X } from '@phosphor-icons/react';
import { api } from '../../../../../convex/_generated/api';
import { useBackHandler } from '../../hooks/useBackHandler';
import styles from './MobileCreateChannelPage.module.css';
interface MobileCreateChannelPageProps {
isOpen: boolean;
onClose: () => void;
}
type ChannelType = 'text' | 'voice';
const TYPE_OPTIONS: Array<{
value: ChannelType;
label: string;
description: string;
Icon: typeof Hash;
}> = [
{
value: 'text',
label: 'Text',
description: 'Post images, GIFs, stickers, opinions, and puns',
Icon: Hash,
},
{
value: 'voice',
label: 'Voice',
description: 'Hang out together with voice, video, and screen share',
Icon: SpeakerHigh,
},
];
export function MobileCreateChannelPage({
isOpen,
onClose,
}: MobileCreateChannelPageProps) {
const createChannel = useMutation(api.channels.create);
const [name, setName] = useState('');
const [type, setType] = useState<ChannelType>('text');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
setName('');
setType('text');
setLoading(false);
setError(null);
}
}, [isOpen]);
useBackHandler(isOpen, onClose);
if (!isOpen) return null;
const trimmed = name.trim();
const canCreate = trimmed.length > 0 && !loading;
const handleCreate = async () => {
if (!canCreate) return;
setLoading(true);
setError(null);
try {
await createChannel({ name: trimmed, type });
onClose();
} catch (err: any) {
setError(err?.message || 'Failed to create channel.');
} finally {
setLoading(false);
}
};
return createPortal(
<div className={styles.page}>
<header className={styles.header}>
<button
type="button"
className={styles.headerButton}
onClick={onClose}
aria-label="Close"
>
<X size={22} weight="bold" />
</button>
<h1 className={styles.headerTitle}>Create Channel</h1>
<button
type="button"
className={`${styles.headerAction} ${
canCreate ? styles.headerActionActive : ''
}`}
onClick={handleCreate}
disabled={!canCreate}
>
{loading ? 'Creating…' : 'Create'}
</button>
</header>
<main className={styles.body}>
<label className={styles.fieldLabel}>Channel Name</label>
<input
type="text"
className={styles.input}
value={name}
maxLength={100}
onChange={(e) => setName(e.target.value)}
placeholder="new-channel"
autoFocus
/>
<h2 className={styles.sectionHeading}>Channel Type</h2>
<div className={styles.typeList}>
{TYPE_OPTIONS.map((opt, idx) => {
const selected = type === opt.value;
return (
<button
key={opt.value}
type="button"
className={`${styles.typeOption} ${
selected ? styles.typeOptionSelected : ''
} ${idx === 0 ? styles.typeOptionFirst : ''} ${
idx === TYPE_OPTIONS.length - 1
? styles.typeOptionLast
: ''
}`}
onClick={() => setType(opt.value)}
>
<span className={styles.typeIcon}>
<opt.Icon size={20} weight="bold" />
</span>
<span className={styles.typeMeta}>
<span className={styles.typeLabel}>{opt.label}</span>
<span className={styles.typeDescription}>
{opt.description}
</span>
</span>
<span
className={`${styles.radio} ${
selected ? styles.radioSelected : ''
}`}
/>
</button>
);
})}
</div>
{error && <p className={styles.error}>{error}</p>}
</main>
</div>,
document.body,
);
}

View File

@@ -1,250 +0,0 @@
.container {
position: fixed;
top: 0;
left: 0;
z-index: calc(var(--z-index-modal, 10000) + 1);
border-radius: 12px;
overflow: hidden;
box-shadow:
0 8px 32px var(--voice-shadow-strong),
0 2px 8px var(--voice-shadow-soft);
cursor: grab;
user-select: none;
touch-action: none;
background-color: var(--voice-surface-2);
will-change: transform;
}
.container:active {
cursor: grabbing;
}
.videoWrapper {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: inherit;
}
.videoWrapper video {
width: 100%;
height: 100%;
object-fit: contain;
background-color: var(--voice-surface-0);
border-radius: inherit;
display: block;
}
.screenShareVideo {
object-fit: contain;
}
/* ── Top gradient + return-to-call button ───────────────────── */
.headerGradient {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 48px;
background: linear-gradient(to bottom, var(--voice-overlay-strong) 0%, transparent 100%);
display: flex;
align-items: flex-start;
padding: 8px 10px;
pointer-events: auto;
z-index: 3;
}
.returnToCallButton {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--voice-text-strong);
cursor: pointer;
background: none;
border: none;
padding: 0;
font-size: 0.75rem;
font-weight: 600;
border-bottom: 1px solid transparent;
transition: border-color 150ms;
}
.returnToCallButton:hover {
border-bottom-color: currentColor;
}
/* ── Bottom gradient + streamer name ───────────────────────── */
.footerGradient {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 48px;
background: linear-gradient(to top, var(--voice-overlay-strong) 0%, transparent 100%);
display: flex;
align-items: flex-end;
padding: 8px 10px;
pointer-events: none;
z-index: 3;
}
.streamerName {
font-size: 0.75rem;
font-weight: 500;
color: var(--voice-text-strong);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Resize handles ─────────────────────────────────────────── */
.resizeHandleTop,
.resizeHandleBottom,
.resizeHandleLeft,
.resizeHandleRight,
.resizeHandleTopLeft,
.resizeHandleTopRight,
.resizeHandleBottomLeft,
.resizeHandleBottomRight {
position: absolute;
border: none;
background: transparent;
z-index: 4;
pointer-events: auto;
padding: 0;
}
.resizeHandleTop {
top: 0;
left: 16px;
right: 16px;
height: 8px;
cursor: n-resize;
}
.resizeHandleBottom {
bottom: 0;
left: 16px;
right: 16px;
height: 8px;
cursor: s-resize;
}
.resizeHandleLeft {
top: 16px;
left: 0;
bottom: 16px;
width: 8px;
cursor: w-resize;
}
.resizeHandleRight {
top: 16px;
right: 0;
bottom: 16px;
width: 8px;
cursor: e-resize;
}
.resizeHandleTopLeft {
top: 0;
left: 0;
width: 16px;
height: 16px;
cursor: nw-resize;
}
.resizeHandleTopRight {
top: 0;
right: 0;
width: 16px;
height: 16px;
cursor: ne-resize;
}
.resizeHandleBottomLeft {
bottom: 0;
left: 0;
width: 16px;
height: 16px;
cursor: sw-resize;
}
.resizeHandleBottomRight {
bottom: 0;
right: 0;
width: 16px;
height: 16px;
cursor: se-resize;
}
.pipContent {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
padding: 12px 14px;
gap: 4px;
}
.pipLabel {
font-size: 11px;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.pipChannel {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pipActions {
display: flex;
gap: 8px;
margin-top: 6px;
}
.pipPrimary {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
border: none;
border-radius: 4px;
background-color: var(--brand-primary, #4641d9);
color: #fff;
font: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.pipPrimary:hover {
filter: brightness(1.1);
}
.pipDanger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
background-color: var(--status-danger, #ed4245);
color: #fff;
cursor: pointer;
}
.pipDanger:hover {
filter: brightness(1.1);
}

View File

@@ -1,133 +0,0 @@
/**
* PiPOverlay — floating voice-call status widget shown when the user
* is connected to a voice channel but has navigated to a different
* channel / DM / route. Presents the channel name, a "Return to
* channel" button, and a disconnect button. The widget is draggable
* via pointer events.
*
* The new UI's screen-share video PiP required live LiveKit track
* binding from a MobX store; our Convex/React voice context doesn't
* expose that the same way, so this is a simpler status widget
* focused on the common case: "I'm in a call, I want to get back."
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { PhoneDisconnect, ArrowRight } from '@phosphor-icons/react';
import { useVoice } from '../../contexts/VoiceContext';
import styles from './PiPOverlay.module.css';
interface Box {
x: number;
y: number;
}
const WIDTH = 260;
const HEIGHT = 96;
function clamp(v: number, min: number, max: number): number {
return Math.max(min, Math.min(max, v));
}
export function PiPOverlay() {
const navigate = useNavigate();
const location = useLocation();
const voice = useVoice() as any;
const [box, setBox] = useState<Box>(() => ({
x: Math.max(16, window.innerWidth - WIDTH - 24),
y: Math.max(16, window.innerHeight - HEIGHT - 24),
}));
const dragRef = useRef<{ startX: number; startY: number; startBox: Box } | null>(null);
useEffect(() => {
const handleMove = (e: PointerEvent) => {
const drag = dragRef.current;
if (!drag) return;
const dx = e.clientX - drag.startX;
const dy = e.clientY - drag.startY;
setBox({
x: clamp(drag.startBox.x + dx, 0, window.innerWidth - WIDTH),
y: clamp(drag.startBox.y + dy, 0, window.innerHeight - HEIGHT),
});
};
const handleUp = () => {
dragRef.current = null;
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleUp);
return () => {
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleUp);
};
}, []);
const startDrag = useCallback(
(e: React.PointerEvent) => {
// Don't initiate a drag when the pointer lands on a button.
const target = e.target as HTMLElement;
if (target.closest('button')) return;
e.preventDefault();
dragRef.current = {
startX: e.clientX,
startY: e.clientY,
startBox: { ...box },
};
},
[box],
);
const activeChannelId: string | null = voice?.activeChannelId ?? null;
const activeChannelName: string | null = voice?.activeChannelName ?? null;
// Hide the overlay when not in a call or when the user is already
// looking at the active voice channel.
if (!activeChannelId) return null;
const onActiveChannelRoute =
location.pathname.includes(`/${activeChannelId}`);
if (onActiveChannelRoute) return null;
const handleReturn = () => {
navigate(`/channels/home/${activeChannelId}`);
};
const handleDisconnect = () => {
voice?.disconnect?.();
};
return (
<div
className={styles.container}
style={{
transform: `translate(${box.x}px, ${box.y}px)`,
width: WIDTH,
height: HEIGHT,
}}
onPointerDown={startDrag}
>
<div className={styles.pipContent}>
<div className={styles.pipLabel}>In voice</div>
<div className={styles.pipChannel}>
#{activeChannelName || 'channel'}
</div>
<div className={styles.pipActions}>
<button
type="button"
className={styles.pipPrimary}
onClick={handleReturn}
aria-label="Return to channel"
>
<ArrowRight size={14} weight="bold" />
<span>Return</span>
</button>
<button
type="button"
className={styles.pipDanger}
onClick={handleDisconnect}
aria-label="Disconnect"
>
<PhoneDisconnect size={14} weight="bold" />
</button>
</div>
</div>
</div>
);
}

View File

@@ -60,6 +60,24 @@
color: var(--voice-text-strong);
}
.backButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: transparent;
border: none;
color: var(--voice-text-strong);
cursor: pointer;
border-radius: 8px;
-webkit-tap-highlight-color: transparent;
}
.backButton:active {
background-color: rgba(255, 255, 255, 0.08);
}
.channelIcon {
color: var(--voice-text-muted);
}

View File

@@ -1,5 +1,7 @@
import { Hash } from '@phosphor-icons/react';
import { ArrowLeft, Hash } from '@phosphor-icons/react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useVoice } from '../../contexts/VoiceContext';
import { useIsMobile } from '../../hooks/useIsMobile';
import { ScreenSharePreview } from './ScreenSharePreview';
import { VoiceGridLayout } from './VoiceGridLayout';
import { VoiceControlBar } from './VoiceControlBar';
@@ -22,6 +24,21 @@ interface RawParticipant {
export function VoiceCallView({ channelId }: VoiceCallViewProps) {
const voice = useVoice();
const isMobile = useIsMobile();
const navigate = useNavigate();
const location = useLocation();
// On mobile the call surface fills the whole viewport and the
// channel list isn't visible, so expose an explicit way back.
// Drop the last route segment (the channelId) so we land on the
// server's nav column (e.g. /channels/home/xyz → /channels/home),
// which `GuildsLayout` renders as the sidebar on mobile.
const handleBack = () => {
const segments = location.pathname.split('/').filter(Boolean);
if (segments[0] === 'channels' && segments.length >= 3) {
navigate(`/${segments[0]}/${segments[1]}`);
}
};
const rawParticipants: RawParticipant[] =
(voice?.voiceStates && voice.voiceStates[channelId]) || [];
@@ -42,6 +59,16 @@ export function VoiceCallView({ channelId }: VoiceCallViewProps) {
<div className={`${styles.root} ${styles.pointerActive}`} data-voice-call-root>
<div className={styles.voiceHeader}>
<div className={styles.headerLeftSection}>
{isMobile && (
<button
type="button"
className={styles.backButton}
onClick={handleBack}
aria-label="Back to channel list"
>
<ArrowLeft size={22} weight="bold" />
</button>
)}
<div className={styles.channelInfoContainer}>
<Hash size={20} weight="bold" className={styles.channelIcon} />
<span className={styles.channelName}>{channelName}</span>

View File

@@ -82,6 +82,11 @@ export const VoiceProvider = ({ children }) => {
);
const isMovingRef = useRef(false);
const isDMCallRef = useRef(false);
// Flipped to true by `disconnectVoice` when the user presses
// disconnect *during* the 'connecting' phase. Each major await in
// `connectToVoice` checks this ref and bails out cleanly so we
// don't end up connected to a channel the user tried to leave.
const cancelConnectRef = useRef(false);
const lastSpokeRef = useRef(Date.now());
const [isReceivingScreenShareAudio, setIsReceivingScreenShareAudio] = useState(false);
const [isReconnecting, setIsReconnecting] = useState(false);
@@ -288,6 +293,20 @@ export const VoiceProvider = ({ children }) => {
const connectToVoice = async (channelId, channelName, userId, isDMCall = false) => {
if (activeChannelId === channelId) return;
isDMCallRef.current = isDMCall;
cancelConnectRef.current = false;
// Channel hop — we're already in a voice room. Flip the
// move-in-progress flag BEFORE disconnecting so the old
// room's RoomEvent.Disconnected handler takes the short
// "moving" branch: it clears room/token without touching
// activeChannelId, skips the voiceState.leave mutation, and
// skips the leave sound. Without this the handler races
// against the rest of this function — nulling the channel
// id we're about to set and calling leave() between our
// disconnect and the new voiceState.join, which looks like
// "joins, plays sound, drops out" from the user's side.
const isMove = !!room;
if (isMove) isMovingRef.current = true;
if (room) await room.disconnect();
@@ -296,6 +315,38 @@ export const VoiceProvider = ({ children }) => {
setConnectionState('connecting');
window.__inVoiceCall = true;
// Cancellation check — called after every major async step.
// If the user pressed disconnect while we were awaiting, roll
// back: close the partially-built room (if any; its
// Disconnected handler cleans up state), otherwise reset the
// pre-room UI state manually. Returns true when the caller
// should bail.
const abortIfCancelled = (builtRoom) => {
if (!cancelConnectRef.current) return false;
if (builtRoom) {
builtRoom.disconnect().catch(() => {});
}
// Always reset UI state on cancel, even when a room is
// being torn down. The Disconnected handler's "moving"
// branch preserves activeChannelId (needed for legit
// moves), so we explicitly clear it here.
setActiveChannelId(null);
setActiveChannelName(null);
setConnectionState('disconnected');
setToken(null);
window.__inVoiceCall = false;
voiceService?.stopService();
// If this was a channel hop we flipped isMovingRef on;
// reset it so a later disconnect doesn't wrongly skip
// voiceState.leave.
isMovingRef.current = false;
// Clean up any stale server-side voice state (the old
// channel's row survives a cancelled move because the
// "moving" branch skips voiceState.leave).
convex.mutation(api.voiceState.leave, { userId }).catch(() => {});
return true;
};
try {
// Request microphone permission (triggers Android runtime prompt)
try {
@@ -308,6 +359,8 @@ export const VoiceProvider = ({ children }) => {
return;
}
if (abortIfCancelled(null)) return;
// Prove we control `userId` by signing the (userId, channelId,
// timestamp) tuple with the Ed25519 key decrypted at login.
// The server verifies with our public signing key before minting
@@ -323,6 +376,8 @@ export const VoiceProvider = ({ children }) => {
const message = `voice-token:${userId}:${channelId}:${timestamp}`;
const signature = await platform.crypto.signMessage(signingKey, message);
if (abortIfCancelled(null)) return;
const tokenResult = await convex.action(api.voice.getToken, {
channelId,
userId,
@@ -337,6 +392,8 @@ export const VoiceProvider = ({ children }) => {
return;
}
if (abortIfCancelled(null)) return;
const lkToken = tokenResult.token;
if (!lkToken) throw new Error('Failed to get token');
@@ -411,6 +468,10 @@ export const VoiceProvider = ({ children }) => {
});
await newRoom.connect(import.meta.env.VITE_LIVEKIT_URL, lkToken);
// Cancelled during the LiveKit handshake — tear the room
// back down before we wire any more state around it.
if (abortIfCancelled(newRoom)) return;
if (storedOutputDevice && storedOutputDevice !== 'default') {
await newRoom.switchActiveDevice('audiooutput', storedOutputDevice).catch(e =>
console.warn('Failed to switch output device on connect:', e)
@@ -419,6 +480,8 @@ export const VoiceProvider = ({ children }) => {
await newRoom.localParticipant.setMicrophoneEnabled(!isMuted && !isDeafened);
if (abortIfCancelled(newRoom)) return;
setRoom(newRoom);
setConnectionState('connected');
// Start native foreground service for background voice on Android
@@ -430,22 +493,12 @@ export const VoiceProvider = ({ children }) => {
playSound('join');
}
await convex.mutation(api.voiceState.join, {
channelId,
userId,
username: localStorage.getItem('username') || 'Unknown',
isMuted,
isDeafened,
});
// Auto-mute when joining AFK channel
if (serverSettings?.afkChannelId === channelId) {
setIsMuted(true);
await newRoom.localParticipant.setMicrophoneEnabled(false);
await convex.mutation(api.voiceState.updateState, { userId, isMuted: true });
voiceService?.updateNotification({ isMuted: true });
}
// Register the Disconnected / Reconnecting / etc. handlers
// BEFORE `voiceState.join`. If the user taps disconnect in
// the narrow window between `setRoom` and this mutation
// returning, `disconnectVoice` calls `room.disconnect()` —
// without the handler bound there's nothing to tear down
// state. Keeping registration first closes that gap.
newRoom.on(RoomEvent.Disconnected, async (reason) => {
console.warn('Voice Room Disconnected. Reason:', reason);
// If we're being moved, skip leave mutation — we'll reconnect shortly
@@ -556,11 +609,46 @@ export const VoiceProvider = ({ children }) => {
}));
});
// Commit the server-side voice state LAST. Until this
// resolves the reactive `voiceStates` query still reports
// us in the old channel, which is why we keep
// `isMovingRef.current === true` across the mutation —
// otherwise the reconcile effect below sees "server says
// A, client says B" and kicks off a spurious reconnect
// back to A (the bug that caused double-join / double-
// leave sounds when switching channels).
await convex.mutation(api.voiceState.join, {
channelId,
userId,
username: localStorage.getItem('username') || 'Unknown',
isMuted,
isDeafened,
});
// Auto-mute when joining AFK channel
if (serverSettings?.afkChannelId === channelId) {
setIsMuted(true);
await newRoom.localParticipant.setMicrophoneEnabled(false);
await convex.mutation(api.voiceState.updateState, { userId, isMuted: true });
voiceService?.updateNotification({ isMuted: true });
}
// Now that the server state has been committed and the
// reactive query will (imminently) show us in `channelId`,
// it's safe to re-enable the reconcile effect. A subtle
// point: the query push can land either before or after
// the mutation promise resolves; both orderings are fine
// because in either case by the time the effect next
// reads `voiceStates` it'll either agree with
// activeChannelId or this flag will still be true.
isMovingRef.current = false;
} catch (err) {
console.error('Voice Connection Failed:', err);
setConnectionState('error');
setActiveChannelId(null);
window.__inVoiceCall = false;
isMovingRef.current = false;
}
};
@@ -975,6 +1063,38 @@ export const VoiceProvider = ({ children }) => {
const disconnectVoice = () => {
console.log('User manually disconnected voice');
isDMCallRef.current = false;
// Mid-connect cancel: `connectToVoice` is awaiting somewhere
// (mic permission, Convex token action, LiveKit handshake).
// Flip the cancel ref so the next `abortIfCancelled` check
// tears things down, and reset visible UI state immediately
// so the button reflects the user's intent even before the
// in-flight awaits unwind.
if (connectionState === 'connecting') {
cancelConnectRef.current = true;
setConnectionState('disconnected');
setActiveChannelId(null);
setActiveChannelName(null);
setToken(null);
window.__inVoiceCall = false;
voiceService?.stopService();
// If the LiveKit connect already completed before the
// cancel fired, the room exists in state and we still
// need to drop it. The RoomEvent.Disconnected handler
// will run voiceState.leave and finish cleanup.
if (room) room.disconnect().catch(() => {});
return;
}
// A user-initiated disconnect always wants the "leave for
// real" path, even if we're still inside the
// `connectToVoice(new)` move window (connectionState ===
// 'connected' but `isMovingRef` hasn't cleared yet because
// `voiceState.join` is in flight). Clearing the move flag
// here makes the Disconnected handler take the normal
// branch: play leave, clear activeChannelId, run
// voiceState.leave.
isMovingRef.current = false;
window.__inVoiceCall = false;
voiceService?.stopService();
if (room) room.disconnect();

View File

@@ -37,7 +37,19 @@
overflow: hidden;
pointer-events: auto;
padding-bottom: env(safe-area-inset-bottom, 0);
touch-action: pan-y;
will-change: transform;
}
.dragZone {
touch-action: none;
user-select: none;
-webkit-user-select: none;
cursor: grab;
flex-shrink: 0;
}
.dragZone:active {
cursor: grabbing;
}
.handleRow {
@@ -46,7 +58,6 @@
justify-content: center;
padding: 8px 0 4px;
flex-shrink: 0;
cursor: grab;
}
.handle {
@@ -106,4 +117,5 @@
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useCallback, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { AnimatePresence, motion, useDragControls } from 'framer-motion';
import { X } from '@phosphor-icons/react';
import clsx from 'clsx';
import styles from './BottomSheet.module.css';
@@ -164,6 +164,22 @@ export function BottomSheet({
const targetY = snap === 'full' ? snapFullPx : snapInitialPx;
// Framer Motion drag controls — we attach drag listening only to a
// dedicated "drag zone" (handle + header) rather than the whole
// sheet. This keeps the body's native scroll from competing with
// the drag gesture, which was the main source of the sluggish/
// inconsistent feel on Android: when the finger landed on body
// content the browser arbitrated scroll-vs-drag every frame.
const dragControls = useDragControls();
const dragEnabled = dismissible && draggable;
const hasDragZone =
dragEnabled && (showHandle || (!disableDefaultHeader && !!title));
const handleDragZonePointerDown = (e: React.PointerEvent) => {
if (!dragEnabled) return;
dragControls.start(e);
};
return createPortal(
<AnimatePresence>
{isOpen && (
@@ -187,9 +203,15 @@ export function BottomSheet({
initial={{ y: '100%' }}
animate={{ y: targetY }}
exit={{ y: '100%' }}
transition={{ type: 'spring', stiffness: 400, damping: 36 }}
transition={{ type: 'spring', stiffness: 500, damping: 42 }}
onClick={(e) => e.stopPropagation()}
drag={dismissible && draggable ? 'y' : false}
drag={dragEnabled ? 'y' : false}
// When a drag zone exists, only it can initiate drag.
// Otherwise (rare: no handle + no header) fall back
// to dragging anywhere on the sheet.
dragListener={hasDragZone ? false : dragEnabled}
dragControls={dragEnabled ? dragControls : undefined}
dragMomentum={false}
dragConstraints={{
// Allow dragging UP to the fully-open snap when
// the sheet is partial and expandable; otherwise
@@ -197,39 +219,33 @@ export function BottomSheet({
top: partial && expandable ? snapFullPx - targetY : 0,
bottom: snapClosedPx - targetY,
}}
dragElastic={{ top: partial && expandable ? 0.05 : 0, bottom: 0.2 }}
dragElastic={{
top: partial && expandable ? 0.12 : 0,
bottom: 0.12,
}}
onDragEnd={(_, info) => {
const finalY = targetY + info.offset.y;
// Velocity-driven flicks first — they win over
// position-based heuristics so a fast swipe
// always does the obvious thing.
if (info.velocity.y > 600) {
// Strong downward flick → close.
// always does the obvious thing. Threshold
// lowered from 600 px/s so deliberate (non-
// flick) drags also register.
if (info.velocity.y > 400) {
onClose();
return;
}
if (partial && expandable && info.velocity.y < -600) {
// Strong upward flick → expand to full.
if (partial && expandable && info.velocity.y < -400) {
setSnap('full');
return;
}
// Position-based snap fallback.
if (!partial) {
// Original behavior — close if dragged
// down past the threshold, otherwise
// spring back to fully open.
if (info.offset.y > 120) onClose();
return;
}
// Partial sheet position-based snap:
// - past the initial position by half its
// distance to closed → close
// - between snaps → snap to nearest
// - above the initial position by 1/3 the
// distance to full → expand
const closeThreshold = snapInitialPx + (snapClosedPx - snapInitialPx) * 0.4;
const expandThreshold = snapInitialPx - snapInitialPx * 0.33;
@@ -244,24 +260,53 @@ export function BottomSheet({
setSnap('initial');
}}
>
{showHandle && (
<div className={styles.handleRow}>
<div className={styles.handle} />
</div>
)}
{hasDragZone ? (
<div
className={styles.dragZone}
onPointerDown={handleDragZonePointerDown}
>
{showHandle && (
<div className={styles.handleRow}>
<div className={styles.handle} />
</div>
)}
{!disableDefaultHeader && title && (
<div className={styles.header}>
<h2 className={styles.title}>{title}</h2>
<button
type="button"
className={styles.closeButton}
onClick={onClose}
aria-label="Close"
>
<X size={20} weight="bold" />
</button>
{!disableDefaultHeader && title && (
<div className={styles.header}>
<h2 className={styles.title}>{title}</h2>
<button
type="button"
className={styles.closeButton}
onClick={onClose}
onPointerDown={(e) => e.stopPropagation()}
aria-label="Close"
>
<X size={20} weight="bold" />
</button>
</div>
)}
</div>
) : (
<>
{showHandle && (
<div className={styles.handleRow}>
<div className={styles.handle} />
</div>
)}
{!disableDefaultHeader && title && (
<div className={styles.header}>
<h2 className={styles.title}>{title}</h2>
<button
type="button"
className={styles.closeButton}
onClick={onClose}
aria-label="Close"
>
<X size={20} weight="bold" />
</button>
</div>
)}
</>
)}
<div className={styles.body}>{children}</div>