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 minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 27 versionCode 27
versionName "1.1.1" versionName "1.1.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

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

View File

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

View File

@@ -159,6 +159,13 @@ export const getAll = query({
avatarUrl: string | null; avatarUrl: string | null;
joinSoundUrl: string | null; joinSoundUrl: string | null;
watchingStream: 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) { for (const s of states) {
@@ -183,6 +190,7 @@ export const getAll = query({
avatarUrl, avatarUrl,
joinSoundUrl, joinSoundUrl,
watchingStream: s.watchingStream ?? null, watchingStream: s.watchingStream ?? null,
joinedAt: s._creationTime,
}); });
} }

View File

@@ -1,7 +1,7 @@
{ {
"name": "@discord-clone/shared", "name": "@discord-clone/shared",
"private": true, "private": true,
"version": "1.1.1", "version": "1.1.2",
"type": "module", "type": "module",
"main": "src/App.tsx", "main": "src/App.tsx",
"dependencies": { "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 // of web video; when the real metadata lands and differs slightly
// the ResizeObserver catches it, but the gross box is already // the ResizeObserver catches it, but the gross box is already
// there. // 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 ( return (
<div className={`${styles.embed} ${styles.embedBare}`}> <div className={`${styles.embed} ${styles.embedBare}`}>
<div <div
@@ -237,7 +244,7 @@ function DirectMediaEmbed({
<video <video
ref={videoRef} ref={videoRef}
className={styles.directVideo} className={styles.directVideo}
src={url} src={posterSrc}
preload="metadata" preload="metadata"
style={{ width: '100%', height: '100%' }} style={{ width: '100%', height: '100%' }}
onLoadedMetadata={() => { onLoadedMetadata={() => {

View File

@@ -19,6 +19,7 @@ import {
import { DeleteConfirmationModal } from './DeleteConfirmationModal'; import { DeleteConfirmationModal } from './DeleteConfirmationModal';
import { PinConfirmationModal } from './PinConfirmationModal'; import { PinConfirmationModal } from './PinConfirmationModal';
import { ReactionsModal } from './ReactionsModal'; import { ReactionsModal } from './ReactionsModal';
import { MobileReactionsDrawer } from './MobileReactionsDrawer';
import { Tooltip } from '@discord-clone/ui'; import { Tooltip } from '@discord-clone/ui';
import { reactionKeyToName } from '../../utils/emojiLookup'; import { reactionKeyToName } from '../../utils/emojiLookup';
import type { PinnedMessage } from './PinnedMessageRow'; import type { PinnedMessage } from './PinnedMessageRow';
@@ -150,6 +151,11 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
const [reactionsModalMsgId, setReactionsModalMsgId] = useState< const [reactionsModalMsgId, setReactionsModalMsgId] = useState<
string | null string | null
>(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 longPressTimerRef = useRef<number | null>(null);
const isMobile = useIsMobile(); 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 && ( {mobileSheetForMsg && (
<MobileMessageActionsSheet <MobileMessageActionsSheet
isOpen={!!mobileSheetForMsg} isOpen={!!mobileSheetForMsg}
@@ -754,6 +790,11 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
anchor: null as any, anchor: null as any,
}); });
}} }}
onViewReactions={
mobileSheetForMsg.reactions.length > 0
? () => setMobileReactionsMsgId(mobileSheetForMsg.id)
: undefined
}
onReply={() => { onReply={() => {
onReply?.(mobileSheetForMsg.id, first.authorName); 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 { import {
Smiley, Smiley,
SmileyWink,
ArrowBendUpLeft, ArrowBendUpLeft,
ArrowBendUpRight, ArrowBendUpRight,
PencilSimple, PencilSimple,
@@ -68,6 +69,11 @@ interface MobileMessageActionsSheetProps {
onQuickReact: (emoji: string) => void; onQuickReact: (emoji: string) => void;
/** Opens the full reaction picker. */ /** Opens the full reaction picker. */
onAddReaction: () => void; 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; onReply: () => void;
onForward: () => void; onForward: () => void;
onEdit?: () => void; onEdit?: () => void;
@@ -91,6 +97,7 @@ export function MobileMessageActionsSheet({
hasContent, hasContent,
onQuickReact, onQuickReact,
onAddReaction, onAddReaction,
onViewReactions,
onReply, onReply,
onForward, onForward,
onEdit, onEdit,
@@ -154,12 +161,24 @@ export function MobileMessageActionsSheet({
</div> </div>
{/* Group 1 — Add Reaction lives alone so it visually {/* 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}> <div className={styles.actionList}>
<button type="button" className={styles.actionItem} onClick={wrap(onAddReaction)}> <button type="button" className={styles.actionItem} onClick={wrap(onAddReaction)}>
<Smiley size={20} weight="fill" /> <Smiley size={20} weight="fill" />
<span>Add Reaction</span> <span>Add Reaction</span>
</button> </button>
{onViewReactions && (
<button
type="button"
className={styles.actionItem}
onClick={wrap(onViewReactions)}
>
<SmileyWink size={20} weight="fill" />
<span>Reactions</span>
</button>
)}
</div> </div>
{/* Group 2 — message interaction actions (Reply / {/* 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 { CreateCategoryModal } from './CategorySettingsModal';
import { InviteModal } from '../modals/InviteModal'; import { InviteModal } from '../modals/InviteModal';
import { CreateServerModal } from '../modals/CreateServerModal'; import { CreateServerModal } from '../modals/CreateServerModal';
import { PiPOverlay } from '../voice/PiPOverlay';
import { RecordingRecoveryModal } from '../voice/RecordingRecoveryModal'; import { RecordingRecoveryModal } from '../voice/RecordingRecoveryModal';
import { ChannelSettingsModal } from '../channel/ChannelSettingsModal'; 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'; import { UpdateBanner } from './UpdateBanner';
/** /**
@@ -42,6 +45,7 @@ export function AppLayout() {
const [serverSettingsOpen, setServerSettingsOpen] = useState(false); const [serverSettingsOpen, setServerSettingsOpen] = useState(false);
const [createServerOpen, setCreateServerOpen] = useState(false); const [createServerOpen, setCreateServerOpen] = useState(false);
const [channelSettingsId, setChannelSettingsId] = useState<string | null>(null); const [channelSettingsId, setChannelSettingsId] = useState<string | null>(null);
const isMobile = useIsMobile();
// Keybind / cross-component nav bridge // Keybind / cross-component nav bridge
useEffect(() => { useEffect(() => {
@@ -222,25 +226,51 @@ export function AppLayout() {
isOpen={serverSettingsOpen} isOpen={serverSettingsOpen}
onClose={() => setServerSettingsOpen(false)} onClose={() => setServerSettingsOpen(false)}
/> />
<CreateChannelModal {/* Create Channel / Category / Channel Settings all have
isOpen={createChannelOpen} mobile fullscreen variants that take over from the
onClose={() => setCreateChannelOpen(false)} desktop modals under the mobile breakpoint. The other
/> component stays unmounted so there's no duplicate
<CreateCategoryModal portaled chrome or back-stack entries. */}
isOpen={createCategoryOpen} {isMobile ? (
onClose={() => setCreateCategoryOpen(false)} <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)} /> <InviteModal isOpen={inviteOpen} onClose={() => setInviteOpen(false)} />
<CreateServerModal <CreateServerModal
isOpen={createServerOpen} isOpen={createServerOpen}
onClose={() => setCreateServerOpen(false)} onClose={() => setCreateServerOpen(false)}
/> />
<ChannelSettingsModal {isMobile ? (
isOpen={!!channelSettingsId} <MobileChannelSettingsPage
onClose={() => setChannelSettingsId(null)} isOpen={!!channelSettingsId}
channelId={channelSettingsId} onClose={() => setChannelSettingsId(null)}
/> channelId={channelSettingsId}
<PiPOverlay /> />
) : (
<ChannelSettingsModal
isOpen={!!channelSettingsId}
onClose={() => setChannelSettingsId(null)}
channelId={channelSettingsId}
/>
)}
<RecordingRecoveryModal /> <RecordingRecoveryModal />
<UpdateBanner /> <UpdateBanner />
</KeybindProvider> </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 { useEffect, useRef } from 'react';
import styles from './ChannelListContextMenu.module.css'; import styles from './ChannelListContextMenu.module.css';
@@ -25,6 +34,15 @@ interface ChannelListContextMenuProps {
* entry so the user can always nuke a phantom from their sidebar. * entry so the user can always nuke a phantom from their sidebar.
*/ */
onLeaveChannel?: () => void; 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; onClose: () => void;
} }
@@ -42,6 +60,8 @@ export function ChannelListContextMenu({
onRenameChannel, onRenameChannel,
onDeleteChannel, onDeleteChannel,
onLeaveChannel, onLeaveChannel,
onTogglePinChannel,
isPinnedChannel = false,
onClose, onClose,
}: ChannelListContextMenuProps) { }: ChannelListContextMenuProps) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -74,6 +94,18 @@ export function ChannelListContextMenu({
<div className={styles.menu} ref={ref} style={style}> <div className={styles.menu} ref={ref} style={style}>
{channelId ? ( {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 && ( {onRenameChannel && (
<button className={styles.menuItem} onClick={onRenameChannel}> <button className={styles.menuItem} onClick={onRenameChannel}>
<PencilSimple size={18} /> <PencilSimple size={18} />

View File

@@ -221,6 +221,16 @@
color: var(--text-primary) !important; 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 { .channelIcon {
width: 1.25rem; width: 1.25rem;
height: 1.25rem; height: 1.25rem;
@@ -303,6 +313,27 @@
On hover or select, the unread/mention badges are hidden so the gear On hover or select, the unread/mention badges are hidden so the gear
takes their place — same pattern Discord uses. 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 { .channelItemWrapper {
position: relative; position: relative;
min-width: 0; min-width: 0;
@@ -333,16 +364,34 @@
background-color: color-mix(in srgb, var(--text-primary) 10%, transparent); background-color: color-mix(in srgb, var(--text-primary) 10%, transparent);
} }
.channelItemWrapper[data-selected="true"] .channelSettingsGear { /* Voice-channel timer — shown by default for the viewer's own voice
display: flex; channel, tucked into the same right-edge slot the gear lives in.
color: var(--text-primary); Uses the same green as the voice-active icon tint so the "you're
} in voice" signal is visually consistent. */
.channelVoiceTimer {
.channelItemWrapper[data-selected="true"] .channelItem { display: inline-flex;
padding-right: 2.25rem; 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) { @media (hover: hover) and (pointer: fine) {
.channelItemWrapper:hover .channelVoiceTimer {
display: none;
}
.channelItemWrapper:hover .channelSettingsGear { .channelItemWrapper:hover .channelSettingsGear {
display: flex; 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"] .mentionBadge,
.channelItemWrapper[data-selected="true"] .unreadBadge { .channelItemWrapper[data-selected="true"] .unreadBadge {
display: none; 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 ────────────────────────────────────────────── */ /* ── Voice Channel Styles ────────────────────────────────────────────── */
.voiceUsers { .voiceUsers {

View File

@@ -23,6 +23,65 @@ import {
SpeakerHigh, SpeakerHigh,
SpeakerSlash, SpeakerSlash,
} from '@phosphor-icons/react'; } 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 { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { Avatar } from '@discord-clone/ui'; import { Avatar } from '@discord-clone/ui';
@@ -33,12 +92,17 @@ import { getUserPref, setUserPref } from '../../utils/userPreferences';
import { GuildHeaderDropdown } from './GuildHeaderDropdown'; import { GuildHeaderDropdown } from './GuildHeaderDropdown';
import { ChannelListContextMenu } from './ChannelListContextMenu'; import { ChannelListContextMenu } from './ChannelListContextMenu';
import { MobileServerActionsSheet } from './MobileServerActionsSheet'; import { MobileServerActionsSheet } from './MobileServerActionsSheet';
import { MobileChannelActionsSheet } from '../channel/MobileChannelActionsSheet';
import { useIsMobile } from '../../hooks/useIsMobile'; import { useIsMobile } from '../../hooks/useIsMobile';
import { useBackHandler } from '../../hooks/useBackHandler'; import { useBackHandler } from '../../hooks/useBackHandler';
import { VoiceUserContextMenu } from '../voice/VoiceUserContextMenu'; import { VoiceUserContextMenu } from '../voice/VoiceUserContextMenu';
import { MemberProfileModal } from '../member/MemberProfileModal'; import { MemberProfileModal } from '../member/MemberProfileModal';
import styles from './GuildNavbar.module.css'; 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 = { type VoiceParticipant = {
userId: string; userId: string;
username: string; username: string;
@@ -46,6 +110,12 @@ type VoiceParticipant = {
isMuted?: boolean; isMuted?: boolean;
isDeafened?: boolean; isDeafened?: boolean;
isScreenSharing?: 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 = { type ChannelDoc = {
@@ -137,6 +207,36 @@ export function GuildNavbar() {
const deleteChannel = useMutation(api.channels.remove); const deleteChannel = useMutation(api.channels.remove);
const renameCategory = useMutation(api.categories.rename); const renameCategory = useMutation(api.categories.rename);
const deleteCategory = useMutation(api.categories.remove); 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 = type ContextMenuState =
| { | {
@@ -198,6 +298,24 @@ export function GuildNavbar() {
const saved = getUserPref(userId, 'collapsedCategories', null); const saved = getUserPref(userId, 'collapsedCategories', null);
return Array.isArray(saved) ? new Set(saved) : new Set(); 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 headerRef = useRef<HTMLButtonElement>(null);
const [headerDropdownRect, setHeaderDropdownRect] = useState<DOMRect | null>(null); const [headerDropdownRect, setHeaderDropdownRect] = useState<DOMRect | null>(null);
const [mobileServerSheetOpen, setMobileServerSheetOpen] = useState(false); const [mobileServerSheetOpen, setMobileServerSheetOpen] = useState(false);
@@ -205,6 +323,12 @@ export function GuildNavbar() {
{ x: number; y: number; userId: string; username: string } | null { x: number; y: number; userId: string; username: string } | null
>(null); >(null);
const [voiceProfileFor, setVoiceProfileFor] = useState<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(mobileServerSheetOpen, () => setMobileServerSheetOpen(false));
useBackHandler(!!voiceMenu, () => setVoiceMenu(null)); useBackHandler(!!voiceMenu, () => setVoiceMenu(null));
useBackHandler(!!voiceProfileFor, () => setVoiceProfileFor(null)); useBackHandler(!!voiceProfileFor, () => setVoiceProfileFor(null));
@@ -446,8 +570,29 @@ export function GuildNavbar() {
unreadCount={unread?.unreadCount ?? 0} unreadCount={unread?.unreadCount ?? 0}
mentionCount={unread?.mentionCount ?? 0} mentionCount={unread?.mentionCount ?? 0}
participants={participants} 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)} onClick={() => handleChannelClick(ch._id)}
onOpenSettings={() => handleOpenChannelSettings(ch._id)} onOpenSettings={() => handleOpenChannelSettings(ch._id)}
onLongPress={
isMobile
? () =>
setMobileChannelActions({
channelId: ch._id,
channelName: ch.name,
})
: undefined
}
onVoiceUserContextMenu={(p, e) => { onVoiceUserContextMenu={(p, e) => {
setVoiceMenu({ setVoiceMenu({
x: e.clientX, x: e.clientX,
@@ -486,11 +631,16 @@ export function GuildNavbar() {
{categories.map((category) => { {categories.map((category) => {
const catChannels = byCategory.get(category._id) ?? []; const catChannels = byCategory.get(category._id) ?? [];
const isCollapsed = collapsedCategories.has(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 // 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 const visibleChannels = isCollapsed
? catChannels.filter((c) => c._id === selectedChannelId) ? catChannels.filter(
(c) =>
c._id === selectedChannelId ||
pinnedChannels.has(c._id),
)
: catChannels; : catChannels;
return ( return (
<SortableCategory <SortableCategory
@@ -535,10 +685,33 @@ export function GuildNavbar() {
unreadCount={unread?.unreadCount ?? 0} unreadCount={unread?.unreadCount ?? 0}
mentionCount={unread?.mentionCount ?? 0} mentionCount={unread?.mentionCount ?? 0}
participants={participants} 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)} onClick={() => handleChannelClick(ch._id)}
onOpenSettings={() => onOpenSettings={() =>
handleOpenChannelSettings(ch._id) handleOpenChannelSettings(ch._id)
} }
onLongPress={
isMobile
? () =>
setMobileChannelActions({
channelId: ch._id,
channelName: ch.name,
})
: undefined
}
onVoiceUserContextMenu={(p, e) => { onVoiceUserContextMenu={(p, e) => {
setVoiceMenu({ setVoiceMenu({
x: e.clientX, x: e.clientX,
@@ -616,6 +789,19 @@ export function GuildNavbar() {
onCreateCategory={() => onCreateCategory={() =>
window.dispatchEvent(new CustomEvent('brycord:open-create-category')) 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={ onRenameChannel={
contextMenu.kind === 'channel' contextMenu.kind === 'channel'
? () => { ? () => {
@@ -679,6 +865,36 @@ export function GuildNavbar() {
onClose={() => setContextMenu(null)} 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> </div>
); );
} }
@@ -691,8 +907,37 @@ interface SortableChannelRowProps {
unreadCount?: number; unreadCount?: number;
mentionCount?: number; mentionCount?: number;
participants?: VoiceParticipant[]; 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; onClick: () => void;
onOpenSettings: () => 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; onContextMenu?: (e: React.MouseEvent) => void;
onVoiceUserContextMenu?: ( onVoiceUserContextMenu?: (
participant: VoiceParticipant, participant: VoiceParticipant,
@@ -708,8 +953,13 @@ function SortableChannelRow({
unreadCount = 0, unreadCount = 0,
mentionCount = 0, mentionCount = 0,
participants = [], participants = [],
isPinned = false,
viewerJoinedAt = null,
canMoveVoiceUsers = false,
onMoveVoiceUserDrop,
onClick, onClick,
onOpenSettings, onOpenSettings,
onLongPress,
onContextMenu, onContextMenu,
onVoiceUserContextMenu, onVoiceUserContextMenu,
}: SortableChannelRowProps) { }: SortableChannelRowProps) {
@@ -724,28 +974,140 @@ function SortableChannelRow({
const hasUnread = unreadCount > 0 && !selected; const hasUnread = unreadCount > 0 && !selected;
const hasMention = mentionCount > 0; const hasMention = mentionCount > 0;
const showParticipants = isVoice && participants.length > 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 ( return (
<> <>
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className={`${styles.channelItemWrapper} ${hasUnread ? styles.channelItemUnread : ''}`} className={`${styles.channelItemWrapper} ${hasUnread ? styles.channelItemUnread : ''} ${isDropTarget ? styles.channelItemDropTarget : ''}`}
data-selected={selected ? 'true' : undefined} data-selected={selected ? 'true' : undefined}
data-unread={hasUnread ? 'true' : undefined} data-unread={hasUnread ? 'true' : undefined}
data-drop-target={isDropTarget ? 'true' : undefined}
onContextMenu={onContextMenu} 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} {...attributes}
{...listeners} {...listeners}
> >
<button <button
type="button" type="button"
className={`${styles.channelItem} ${selected ? styles.channelSelected : ''}`} 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} /> // Green-tint the voice glyph whenever anyone is
) : ( // currently connected — makes a live voice channel
<Hash size={20} weight="bold" className={styles.channelIcon} /> // 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 <span
className={`${styles.channelName} ${hasUnread ? styles.channelUnread : ''}`} className={`${styles.channelName} ${hasUnread ? styles.channelUnread : ''}`}
> >
@@ -757,6 +1119,15 @@ function SortableChannelRow({
</span> </span>
)} )}
</button> </button>
{viewerJoinedAt && (
<span
className={styles.channelVoiceTimer}
aria-label="Time in voice channel"
title="Time in voice channel"
>
{formatDuration(now - viewerJoinedAt)}
</span>
)}
<button <button
type="button" type="button"
className={styles.channelSettingsGear} className={styles.channelSettingsGear}
@@ -774,12 +1145,31 @@ function SortableChannelRow({
<div <div
className={styles.voiceUsers} className={styles.voiceUsers}
onPointerDown={(e) => e.stopPropagation()} 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) => ( {participants.map((p) => (
<div <div
key={p.userId} key={p.userId}
className={styles.voiceUser} className={styles.voiceUser}
title={p.username} 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) => { onContextMenu={(e) => {
if (!onVoiceUserContextMenu) return; if (!onVoiceUserContextMenu) return;
e.preventDefault(); 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); 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 { .channelIcon {
color: var(--voice-text-muted); 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 { useVoice } from '../../contexts/VoiceContext';
import { useIsMobile } from '../../hooks/useIsMobile';
import { ScreenSharePreview } from './ScreenSharePreview'; import { ScreenSharePreview } from './ScreenSharePreview';
import { VoiceGridLayout } from './VoiceGridLayout'; import { VoiceGridLayout } from './VoiceGridLayout';
import { VoiceControlBar } from './VoiceControlBar'; import { VoiceControlBar } from './VoiceControlBar';
@@ -22,6 +24,21 @@ interface RawParticipant {
export function VoiceCallView({ channelId }: VoiceCallViewProps) { export function VoiceCallView({ channelId }: VoiceCallViewProps) {
const voice = useVoice(); 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[] = const rawParticipants: RawParticipant[] =
(voice?.voiceStates && voice.voiceStates[channelId]) || []; (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.root} ${styles.pointerActive}`} data-voice-call-root>
<div className={styles.voiceHeader}> <div className={styles.voiceHeader}>
<div className={styles.headerLeftSection}> <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}> <div className={styles.channelInfoContainer}>
<Hash size={20} weight="bold" className={styles.channelIcon} /> <Hash size={20} weight="bold" className={styles.channelIcon} />
<span className={styles.channelName}>{channelName}</span> <span className={styles.channelName}>{channelName}</span>

View File

@@ -82,6 +82,11 @@ export const VoiceProvider = ({ children }) => {
); );
const isMovingRef = useRef(false); const isMovingRef = useRef(false);
const isDMCallRef = 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 lastSpokeRef = useRef(Date.now());
const [isReceivingScreenShareAudio, setIsReceivingScreenShareAudio] = useState(false); const [isReceivingScreenShareAudio, setIsReceivingScreenShareAudio] = useState(false);
const [isReconnecting, setIsReconnecting] = useState(false); const [isReconnecting, setIsReconnecting] = useState(false);
@@ -288,6 +293,20 @@ export const VoiceProvider = ({ children }) => {
const connectToVoice = async (channelId, channelName, userId, isDMCall = false) => { const connectToVoice = async (channelId, channelName, userId, isDMCall = false) => {
if (activeChannelId === channelId) return; if (activeChannelId === channelId) return;
isDMCallRef.current = isDMCall; 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(); if (room) await room.disconnect();
@@ -296,6 +315,38 @@ export const VoiceProvider = ({ children }) => {
setConnectionState('connecting'); setConnectionState('connecting');
window.__inVoiceCall = true; 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 { try {
// Request microphone permission (triggers Android runtime prompt) // Request microphone permission (triggers Android runtime prompt)
try { try {
@@ -308,6 +359,8 @@ export const VoiceProvider = ({ children }) => {
return; return;
} }
if (abortIfCancelled(null)) return;
// Prove we control `userId` by signing the (userId, channelId, // Prove we control `userId` by signing the (userId, channelId,
// timestamp) tuple with the Ed25519 key decrypted at login. // timestamp) tuple with the Ed25519 key decrypted at login.
// The server verifies with our public signing key before minting // 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 message = `voice-token:${userId}:${channelId}:${timestamp}`;
const signature = await platform.crypto.signMessage(signingKey, message); const signature = await platform.crypto.signMessage(signingKey, message);
if (abortIfCancelled(null)) return;
const tokenResult = await convex.action(api.voice.getToken, { const tokenResult = await convex.action(api.voice.getToken, {
channelId, channelId,
userId, userId,
@@ -337,6 +392,8 @@ export const VoiceProvider = ({ children }) => {
return; return;
} }
if (abortIfCancelled(null)) return;
const lkToken = tokenResult.token; const lkToken = tokenResult.token;
if (!lkToken) throw new Error('Failed to get 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); 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') { if (storedOutputDevice && storedOutputDevice !== 'default') {
await newRoom.switchActiveDevice('audiooutput', storedOutputDevice).catch(e => await newRoom.switchActiveDevice('audiooutput', storedOutputDevice).catch(e =>
console.warn('Failed to switch output device on connect:', 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); await newRoom.localParticipant.setMicrophoneEnabled(!isMuted && !isDeafened);
if (abortIfCancelled(newRoom)) return;
setRoom(newRoom); setRoom(newRoom);
setConnectionState('connected'); setConnectionState('connected');
// Start native foreground service for background voice on Android // Start native foreground service for background voice on Android
@@ -430,22 +493,12 @@ export const VoiceProvider = ({ children }) => {
playSound('join'); playSound('join');
} }
await convex.mutation(api.voiceState.join, { // Register the Disconnected / Reconnecting / etc. handlers
channelId, // BEFORE `voiceState.join`. If the user taps disconnect in
userId, // the narrow window between `setRoom` and this mutation
username: localStorage.getItem('username') || 'Unknown', // returning, `disconnectVoice` calls `room.disconnect()` —
isMuted, // without the handler bound there's nothing to tear down
isDeafened, // state. Keeping registration first closes that gap.
});
// 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 });
}
newRoom.on(RoomEvent.Disconnected, async (reason) => { newRoom.on(RoomEvent.Disconnected, async (reason) => {
console.warn('Voice Room Disconnected. Reason:', reason); console.warn('Voice Room Disconnected. Reason:', reason);
// If we're being moved, skip leave mutation — we'll reconnect shortly // 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) { } catch (err) {
console.error('Voice Connection Failed:', err); console.error('Voice Connection Failed:', err);
setConnectionState('error'); setConnectionState('error');
setActiveChannelId(null); setActiveChannelId(null);
window.__inVoiceCall = false; window.__inVoiceCall = false;
isMovingRef.current = false;
} }
}; };
@@ -975,6 +1063,38 @@ export const VoiceProvider = ({ children }) => {
const disconnectVoice = () => { const disconnectVoice = () => {
console.log('User manually disconnected voice'); console.log('User manually disconnected voice');
isDMCallRef.current = false; 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; window.__inVoiceCall = false;
voiceService?.stopService(); voiceService?.stopService();
if (room) room.disconnect(); if (room) room.disconnect();

View File

@@ -37,7 +37,19 @@
overflow: hidden; overflow: hidden;
pointer-events: auto; pointer-events: auto;
padding-bottom: env(safe-area-inset-bottom, 0); 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 { .handleRow {
@@ -46,7 +58,6 @@
justify-content: center; justify-content: center;
padding: 8px 0 4px; padding: 8px 0 4px;
flex-shrink: 0; flex-shrink: 0;
cursor: grab;
} }
.handle { .handle {
@@ -106,4 +117,5 @@
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
} }

View File

@@ -1,6 +1,6 @@
import { useEffect, useCallback, useState, type ReactNode } from 'react'; import { useEffect, useCallback, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom'; 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 { X } from '@phosphor-icons/react';
import clsx from 'clsx'; import clsx from 'clsx';
import styles from './BottomSheet.module.css'; import styles from './BottomSheet.module.css';
@@ -164,6 +164,22 @@ export function BottomSheet({
const targetY = snap === 'full' ? snapFullPx : snapInitialPx; 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( return createPortal(
<AnimatePresence> <AnimatePresence>
{isOpen && ( {isOpen && (
@@ -187,9 +203,15 @@ export function BottomSheet({
initial={{ y: '100%' }} initial={{ y: '100%' }}
animate={{ y: targetY }} animate={{ y: targetY }}
exit={{ y: '100%' }} exit={{ y: '100%' }}
transition={{ type: 'spring', stiffness: 400, damping: 36 }} transition={{ type: 'spring', stiffness: 500, damping: 42 }}
onClick={(e) => e.stopPropagation()} 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={{ dragConstraints={{
// Allow dragging UP to the fully-open snap when // Allow dragging UP to the fully-open snap when
// the sheet is partial and expandable; otherwise // the sheet is partial and expandable; otherwise
@@ -197,39 +219,33 @@ export function BottomSheet({
top: partial && expandable ? snapFullPx - targetY : 0, top: partial && expandable ? snapFullPx - targetY : 0,
bottom: snapClosedPx - targetY, 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) => { onDragEnd={(_, info) => {
const finalY = targetY + info.offset.y; const finalY = targetY + info.offset.y;
// Velocity-driven flicks first — they win over // Velocity-driven flicks first — they win over
// position-based heuristics so a fast swipe // position-based heuristics so a fast swipe
// always does the obvious thing. // always does the obvious thing. Threshold
if (info.velocity.y > 600) { // lowered from 600 px/s so deliberate (non-
// Strong downward flick → close. // flick) drags also register.
if (info.velocity.y > 400) {
onClose(); onClose();
return; return;
} }
if (partial && expandable && info.velocity.y < -600) { if (partial && expandable && info.velocity.y < -400) {
// Strong upward flick → expand to full.
setSnap('full'); setSnap('full');
return; return;
} }
// Position-based snap fallback. // Position-based snap fallback.
if (!partial) { if (!partial) {
// Original behavior — close if dragged
// down past the threshold, otherwise
// spring back to fully open.
if (info.offset.y > 120) onClose(); if (info.offset.y > 120) onClose();
return; 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 closeThreshold = snapInitialPx + (snapClosedPx - snapInitialPx) * 0.4;
const expandThreshold = snapInitialPx - snapInitialPx * 0.33; const expandThreshold = snapInitialPx - snapInitialPx * 0.33;
@@ -244,24 +260,53 @@ export function BottomSheet({
setSnap('initial'); setSnap('initial');
}} }}
> >
{showHandle && ( {hasDragZone ? (
<div className={styles.handleRow}> <div
<div className={styles.handle} /> className={styles.dragZone}
</div> onPointerDown={handleDragZonePointerDown}
)} >
{showHandle && (
<div className={styles.handleRow}>
<div className={styles.handle} />
</div>
)}
{!disableDefaultHeader && title && ( {!disableDefaultHeader && title && (
<div className={styles.header}> <div className={styles.header}>
<h2 className={styles.title}>{title}</h2> <h2 className={styles.title}>{title}</h2>
<button <button
type="button" type="button"
className={styles.closeButton} className={styles.closeButton}
onClick={onClose} onClick={onClose}
aria-label="Close" onPointerDown={(e) => e.stopPropagation()}
> aria-label="Close"
<X size={20} weight="bold" /> >
</button> <X size={20} weight="bold" />
</button>
</div>
)}
</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> <div className={styles.body}>{children}</div>