This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@discord-clone/shared",
|
||||
"private": true,
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"type": "module",
|
||||
"main": "src/App.tsx",
|
||||
"dependencies": {
|
||||
|
||||
10
packages/shared/src/assets/icons/hashtag-pinned.svg
Normal file
10
packages/shared/src/assets/icons/hashtag-pinned.svg
Normal 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 |
12
packages/shared/src/assets/icons/voice-pinned.svg
Normal file
12
packages/shared/src/assets/icons/voice-pinned.svg
Normal 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 |
@@ -224,6 +224,13 @@ function DirectMediaEmbed({
|
||||
// of web video; when the real metadata lands and differs slightly
|
||||
// the ResizeObserver catches it, but the gross box is already
|
||||
// there.
|
||||
//
|
||||
// Appending `#t=0.1` is a media-fragment hint that forces the
|
||||
// browser to seek to 0.1s, which makes it decode and paint that
|
||||
// frame as a pseudo-poster. Android WebView otherwise renders a
|
||||
// blank rectangle for `<video preload="metadata">` because it
|
||||
// only fetches container metadata, not frames.
|
||||
const posterSrc = url.includes('#') ? url : `${url}#t=0.1`;
|
||||
return (
|
||||
<div className={`${styles.embed} ${styles.embedBare}`}>
|
||||
<div
|
||||
@@ -237,7 +244,7 @@ function DirectMediaEmbed({
|
||||
<video
|
||||
ref={videoRef}
|
||||
className={styles.directVideo}
|
||||
src={url}
|
||||
src={posterSrc}
|
||||
preload="metadata"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
onLoadedMetadata={() => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { DeleteConfirmationModal } from './DeleteConfirmationModal';
|
||||
import { PinConfirmationModal } from './PinConfirmationModal';
|
||||
import { ReactionsModal } from './ReactionsModal';
|
||||
import { MobileReactionsDrawer } from './MobileReactionsDrawer';
|
||||
import { Tooltip } from '@discord-clone/ui';
|
||||
import { reactionKeyToName } from '../../utils/emojiLookup';
|
||||
import type { PinnedMessage } from './PinnedMessageRow';
|
||||
@@ -150,6 +151,11 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
||||
const [reactionsModalMsgId, setReactionsModalMsgId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
// Mobile equivalent of the reactions modal — opened from the
|
||||
// "Reactions" row in `MobileMessageActionsSheet`.
|
||||
const [mobileReactionsMsgId, setMobileReactionsMsgId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const longPressTimerRef = useRef<number | null>(null);
|
||||
const isMobile = useIsMobile();
|
||||
@@ -727,6 +733,36 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
||||
);
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
// Mobile "who reacted" drawer — same message lookup
|
||||
// pattern as the desktop modal so it tracks the latest
|
||||
// reaction rows rather than a stale snapshot.
|
||||
const targetMsg = mobileReactionsMsgId
|
||||
? (messages.find((m) => m.id === mobileReactionsMsgId) ?? null)
|
||||
: null;
|
||||
return (
|
||||
<MobileReactionsDrawer
|
||||
isOpen={!!targetMsg}
|
||||
onClose={() => setMobileReactionsMsgId(null)}
|
||||
reactions={targetMsg?.reactions ?? []}
|
||||
myUserId={myUserId}
|
||||
customEmojiByName={customEmojiByName}
|
||||
onRemoveOwnReaction={async (emoji) => {
|
||||
if (!targetMsg || !myUserId) return;
|
||||
try {
|
||||
await removeReaction({
|
||||
messageId: targetMsg.id as any,
|
||||
userId: myUserId as any,
|
||||
emoji,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to remove reaction:', err);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{mobileSheetForMsg && (
|
||||
<MobileMessageActionsSheet
|
||||
isOpen={!!mobileSheetForMsg}
|
||||
@@ -754,6 +790,11 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
||||
anchor: null as any,
|
||||
});
|
||||
}}
|
||||
onViewReactions={
|
||||
mobileSheetForMsg.reactions.length > 0
|
||||
? () => setMobileReactionsMsgId(mobileSheetForMsg.id)
|
||||
: undefined
|
||||
}
|
||||
onReply={() => {
|
||||
onReply?.(mobileSheetForMsg.id, first.authorName);
|
||||
}}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
*/
|
||||
import {
|
||||
Smiley,
|
||||
SmileyWink,
|
||||
ArrowBendUpLeft,
|
||||
ArrowBendUpRight,
|
||||
PencilSimple,
|
||||
@@ -68,6 +69,11 @@ interface MobileMessageActionsSheetProps {
|
||||
onQuickReact: (emoji: string) => void;
|
||||
/** Opens the full reaction picker. */
|
||||
onAddReaction: () => void;
|
||||
/**
|
||||
* Opens the "who reacted" drawer. Only wired (and the Reactions
|
||||
* action shown) when the target message has at least one reaction.
|
||||
*/
|
||||
onViewReactions?: () => void;
|
||||
onReply: () => void;
|
||||
onForward: () => void;
|
||||
onEdit?: () => void;
|
||||
@@ -91,6 +97,7 @@ export function MobileMessageActionsSheet({
|
||||
hasContent,
|
||||
onQuickReact,
|
||||
onAddReaction,
|
||||
onViewReactions,
|
||||
onReply,
|
||||
onForward,
|
||||
onEdit,
|
||||
@@ -154,12 +161,24 @@ export function MobileMessageActionsSheet({
|
||||
</div>
|
||||
|
||||
{/* Group 1 — Add Reaction lives alone so it visually
|
||||
echoes the quick reactions row above it. */}
|
||||
echoes the quick reactions row above it. Reactions
|
||||
(the "who reacted" viewer) shares this card when
|
||||
the message has any existing reactions. */}
|
||||
<div className={styles.actionList}>
|
||||
<button type="button" className={styles.actionItem} onClick={wrap(onAddReaction)}>
|
||||
<Smiley size={20} weight="fill" />
|
||||
<span>Add Reaction</span>
|
||||
</button>
|
||||
{onViewReactions && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.actionItem}
|
||||
onClick={wrap(onViewReactions)}
|
||||
>
|
||||
<SmileyWink size={20} weight="fill" />
|
||||
<span>Reactions</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Group 2 — message interaction actions (Reply /
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
161
packages/shared/src/components/channel/MobileReactionsDrawer.tsx
Normal file
161
packages/shared/src/components/channel/MobileReactionsDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -12,9 +12,12 @@ import { CreateChannelModal } from './CreateChannelModal';
|
||||
import { CreateCategoryModal } from './CategorySettingsModal';
|
||||
import { InviteModal } from '../modals/InviteModal';
|
||||
import { CreateServerModal } from '../modals/CreateServerModal';
|
||||
import { PiPOverlay } from '../voice/PiPOverlay';
|
||||
import { RecordingRecoveryModal } from '../voice/RecordingRecoveryModal';
|
||||
import { ChannelSettingsModal } from '../channel/ChannelSettingsModal';
|
||||
import { MobileChannelSettingsPage } from '../channel/MobileChannelSettingsPage';
|
||||
import { MobileCreateChannelPage } from './MobileCreateChannelPage';
|
||||
import { MobileCreateCategoryPage } from './MobileCreateCategoryPage';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { UpdateBanner } from './UpdateBanner';
|
||||
|
||||
/**
|
||||
@@ -42,6 +45,7 @@ export function AppLayout() {
|
||||
const [serverSettingsOpen, setServerSettingsOpen] = useState(false);
|
||||
const [createServerOpen, setCreateServerOpen] = useState(false);
|
||||
const [channelSettingsId, setChannelSettingsId] = useState<string | null>(null);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Keybind / cross-component nav bridge
|
||||
useEffect(() => {
|
||||
@@ -222,25 +226,51 @@ export function AppLayout() {
|
||||
isOpen={serverSettingsOpen}
|
||||
onClose={() => setServerSettingsOpen(false)}
|
||||
/>
|
||||
<CreateChannelModal
|
||||
isOpen={createChannelOpen}
|
||||
onClose={() => setCreateChannelOpen(false)}
|
||||
/>
|
||||
<CreateCategoryModal
|
||||
isOpen={createCategoryOpen}
|
||||
onClose={() => setCreateCategoryOpen(false)}
|
||||
/>
|
||||
{/* Create Channel / Category / Channel Settings all have
|
||||
mobile fullscreen variants that take over from the
|
||||
desktop modals under the mobile breakpoint. The other
|
||||
component stays unmounted so there's no duplicate
|
||||
portaled chrome or back-stack entries. */}
|
||||
{isMobile ? (
|
||||
<MobileCreateChannelPage
|
||||
isOpen={createChannelOpen}
|
||||
onClose={() => setCreateChannelOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
<CreateChannelModal
|
||||
isOpen={createChannelOpen}
|
||||
onClose={() => setCreateChannelOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{isMobile ? (
|
||||
<MobileCreateCategoryPage
|
||||
isOpen={createCategoryOpen}
|
||||
onClose={() => setCreateCategoryOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
<CreateCategoryModal
|
||||
isOpen={createCategoryOpen}
|
||||
onClose={() => setCreateCategoryOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<InviteModal isOpen={inviteOpen} onClose={() => setInviteOpen(false)} />
|
||||
<CreateServerModal
|
||||
isOpen={createServerOpen}
|
||||
onClose={() => setCreateServerOpen(false)}
|
||||
/>
|
||||
<ChannelSettingsModal
|
||||
isOpen={!!channelSettingsId}
|
||||
onClose={() => setChannelSettingsId(null)}
|
||||
channelId={channelSettingsId}
|
||||
/>
|
||||
<PiPOverlay />
|
||||
{isMobile ? (
|
||||
<MobileChannelSettingsPage
|
||||
isOpen={!!channelSettingsId}
|
||||
onClose={() => setChannelSettingsId(null)}
|
||||
channelId={channelSettingsId}
|
||||
/>
|
||||
) : (
|
||||
<ChannelSettingsModal
|
||||
isOpen={!!channelSettingsId}
|
||||
onClose={() => setChannelSettingsId(null)}
|
||||
channelId={channelSettingsId}
|
||||
/>
|
||||
)}
|
||||
<RecordingRecoveryModal />
|
||||
<UpdateBanner />
|
||||
</KeybindProvider>
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { FolderPlus, Gear, Hash, PencilSimple, SignOut, Trash } from '@phosphor-icons/react';
|
||||
import {
|
||||
FolderPlus,
|
||||
Gear,
|
||||
Hash,
|
||||
PencilSimple,
|
||||
PushPin,
|
||||
PushPinSlash,
|
||||
SignOut,
|
||||
Trash,
|
||||
} from '@phosphor-icons/react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import styles from './ChannelListContextMenu.module.css';
|
||||
|
||||
@@ -25,6 +34,15 @@ interface ChannelListContextMenuProps {
|
||||
* entry so the user can always nuke a phantom from their sidebar.
|
||||
*/
|
||||
onLeaveChannel?: () => void;
|
||||
/**
|
||||
* Toggles the pinned-channel state for the right-clicked channel.
|
||||
* Pinning is a per-user preference stored in `userPreferences` —
|
||||
* pinned channels stay visible even when their category is
|
||||
* collapsed. The menu row toggles between "Pin Channel" and
|
||||
* "Unpin Channel" based on `isPinnedChannel`.
|
||||
*/
|
||||
onTogglePinChannel?: () => void;
|
||||
isPinnedChannel?: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -42,6 +60,8 @@ export function ChannelListContextMenu({
|
||||
onRenameChannel,
|
||||
onDeleteChannel,
|
||||
onLeaveChannel,
|
||||
onTogglePinChannel,
|
||||
isPinnedChannel = false,
|
||||
onClose,
|
||||
}: ChannelListContextMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -74,6 +94,18 @@ export function ChannelListContextMenu({
|
||||
<div className={styles.menu} ref={ref} style={style}>
|
||||
{channelId ? (
|
||||
<>
|
||||
{onTogglePinChannel && (
|
||||
<button className={styles.menuItem} onClick={onTogglePinChannel}>
|
||||
{isPinnedChannel ? (
|
||||
<PushPinSlash size={18} />
|
||||
) : (
|
||||
<PushPin size={18} weight="fill" />
|
||||
)}
|
||||
<span>
|
||||
{isPinnedChannel ? 'Unpin Channel' : 'Pin Channel'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{onRenameChannel && (
|
||||
<button className={styles.menuItem} onClick={onRenameChannel}>
|
||||
<PencilSimple size={18} />
|
||||
|
||||
@@ -221,6 +221,16 @@
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
/* Voice channels with someone currently connected tint the speaker
|
||||
glyph green — matches the "active voice" hint users see in Discord.
|
||||
Wins over the selected-channel rule via specificity of both class
|
||||
selectors; the selected-voice case still sees the green so the
|
||||
signal isn't lost when the user is the one in the channel. */
|
||||
.channelIconVoiceActive,
|
||||
.channelSelected .channelIconVoiceActive {
|
||||
color: hsl(138.837 calc(1 * 36.441%) 53.725% / 1) !important;
|
||||
}
|
||||
|
||||
.channelIcon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
@@ -303,6 +313,27 @@
|
||||
On hover or select, the unread/mention badges are hidden so the gear
|
||||
takes their place — same pattern Discord uses.
|
||||
*/
|
||||
/* Drop-target highlight when a voice user is being dragged onto
|
||||
this channel. The row itself doesn't border well (channel buttons
|
||||
already have their own hover/selected backgrounds), so tint the
|
||||
inner `.channelItem` via the parent-state selector below. */
|
||||
.channelItemDropTarget .channelItem {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
hsl(138.837 calc(1 * 36.441%) 53.725% / 1) 22%,
|
||||
transparent
|
||||
);
|
||||
box-shadow: inset 0 0 0 2px hsl(138.837 calc(1 * 36.441%) 53.725% / 0.9);
|
||||
}
|
||||
|
||||
.voiceUser[draggable="true"] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.voiceUser[draggable="true"]:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.channelItemWrapper {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
@@ -333,16 +364,34 @@
|
||||
background-color: color-mix(in srgb, var(--text-primary) 10%, transparent);
|
||||
}
|
||||
|
||||
.channelItemWrapper[data-selected="true"] .channelSettingsGear {
|
||||
display: flex;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.channelItemWrapper[data-selected="true"] .channelItem {
|
||||
padding-right: 2.25rem;
|
||||
/* Voice-channel timer — shown by default for the viewer's own voice
|
||||
channel, tucked into the same right-edge slot the gear lives in.
|
||||
Uses the same green as the voice-active icon tint so the "you're
|
||||
in voice" signal is visually consistent. */
|
||||
.channelVoiceTimer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
padding: 0 2px;
|
||||
color: hsl(138.837 calc(1 * 36.441%) 53.725% / 1);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Desktop: hover replaces the timer with the gear.
|
||||
Mobile: the timer is always hidden (gear is already hidden, long-
|
||||
press drawer takes over). */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.channelItemWrapper:hover .channelVoiceTimer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.channelItemWrapper:hover .channelSettingsGear {
|
||||
display: flex;
|
||||
}
|
||||
@@ -357,11 +406,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Whenever the timer is rendered, reserve room for it (and for the
|
||||
gear that takes its spot on hover) so the channel name doesn't
|
||||
overlap the right-edge chip. */
|
||||
.channelItemWrapper:has(.channelVoiceTimer) .channelItem {
|
||||
padding-right: 4rem;
|
||||
}
|
||||
|
||||
.channelItemWrapper[data-selected="true"] .mentionBadge,
|
||||
.channelItemWrapper[data-selected="true"] .unreadBadge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile replaces the hover-gear affordance with a long-press
|
||||
drawer (MobileChannelActionsSheet). Hide the gear and the voice
|
||||
timer on the mobile breakpoint, and drop the extra right-padding
|
||||
that was reserved for them. */
|
||||
@media (max-width: 768px) {
|
||||
.channelItemWrapper .channelSettingsGear,
|
||||
.channelItemWrapper[data-selected="true"] .channelSettingsGear,
|
||||
.channelItemWrapper .channelVoiceTimer {
|
||||
display: none !important;
|
||||
}
|
||||
.channelItemWrapper:has(.channelVoiceTimer) .channelItem,
|
||||
.channelItemWrapper[data-selected="true"] .channelItem {
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Voice Channel Styles ────────────────────────────────────────────── */
|
||||
|
||||
.voiceUsers {
|
||||
|
||||
@@ -23,6 +23,65 @@ import {
|
||||
SpeakerHigh,
|
||||
SpeakerSlash,
|
||||
} from '@phosphor-icons/react';
|
||||
|
||||
// Inline the pinned-channel glyphs so `currentColor` inherits from
|
||||
// the row's text color the same way Phosphor's <Hash /> / <SpeakerHigh />
|
||||
// do. An <img src=".../hashtag-pinned.svg"> wouldn't pick up
|
||||
// currentColor, and declaring them here keeps the assets self-contained
|
||||
// for their one caller.
|
||||
function HashtagPinnedIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={20}
|
||||
height={20}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 256 256"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<mask id="hashtag-pinned-mask">
|
||||
<path fill="#fff" d="M0 0h256v256H0z" />
|
||||
<circle cx="192" cy="64" r="60" fill="#000" />
|
||||
</mask>
|
||||
</defs>
|
||||
<path
|
||||
mask="url(#hashtag-pinned-mask)"
|
||||
d="M224 84h-43.8l7.61-41.85a12 12 0 0 0-23.62-4.3L155.8 84h-39.6l7.61-41.85a12 12 0 1 0-23.62-4.3L91.8 84H48a12 12 0 0 0 0 24h39.44l-7.27 40H32a12 12 0 0 0 0 24h43.8l-7.61 41.85a12 12 0 0 0 9.66 14A11.4 11.4 0 0 0 80 228a12 12 0 0 0 11.8-9.86l8.4-46.14h39.6l-7.61 41.85a12 12 0 0 0 9.66 14 11.4 11.4 0 0 0 2.16.2 12 12 0 0 0 11.8-9.86L164.2 172H208a12 12 0 0 0 0-24h-39.44l7.27-40H224a12 12 0 0 0 0-24m-79.83 64h-39.61l7.27-40h39.61Z"
|
||||
/>
|
||||
<path d="M245.665 52 218.93 78.825c2.28 6.335 3.225 16.945-6.595 30A7.97 7.97 0 0 1 206.5 112h-.565a8 8 0 0 1-5.66-2.345L176.145 85.5l-21.315 21.33a4.002 4.002 0 0 1-5.66-5.66l21.33-21.315-24.15-24.15a8 8 0 0 1 .65-11.89c12.71-10.255 24.875-8.24 30.2-6.57L204 10.35a8 8 0 0 1 11.315 0l30.345 30.34a8 8 0 0 1 .005 11.31" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function VoicePinnedIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={20}
|
||||
height={20}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 256 256"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<mask id="voice-pinned-mask">
|
||||
<rect width="256" height="256" fill="white" />
|
||||
<circle cx="192" cy="64" r="60" fill="black" />
|
||||
</mask>
|
||||
</defs>
|
||||
<path
|
||||
mask="url(#voice-pinned-mask)"
|
||||
d="M160,32.25V223.69a8.29,8.29,0,0,1-3.91,7.18,8,8,0,0,1-9-.56l-65.57-51A4,4,0,0,1,80,176.16V79.84a4,4,0,0,1,1.55-3.15l65.57-51a8,8,0,0,1,10,.16A8.27,8.27,0,0,1,160,32.25ZM60,80H32A16,16,0,0,0,16,96v64a16,16,0,0,0,16,16H60a4,4,0,0,0,4-4V84A4,4,0,0,0,60,80Zm126.77,20.84a8,8,0,0,0-.72,11.3,24,24,0,0,1,0,31.72,8,8,0,1,0,12,10.58,40,40,0,0,0,0-52.88A8,8,0,0,0,186.74,100.84Zm40.89-26.17a8,8,0,1,0-11.92,10.66,64,64,0,0,1,0,85.34,8,8,0,1,0,11.92,10.66,80,80,0,0,0,0-106.66Z"
|
||||
/>
|
||||
<g transform="translate(128, 0) scale(0.5)">
|
||||
<path d="M235.33,104l-53.47,53.65c4.56,12.67,6.45,33.89-13.19,60A15.93,15.93,0,0,1,157,224c-.38,0-.75,0-1.13,0a16,16,0,0,1-11.32-4.69L96.29,171,53.66,213.66a8,8,0,0,1-11.32-11.32L85,159.71l-48.3-48.3A16,16,0,0,1,38,87.63c25.42-20.51,49.75-16.48,60.4-13.14L152,20.7a16,16,0,0,1,22.63,0l60.69,60.68A16,16,0,0,1,235.33,104Z" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Avatar } from '@discord-clone/ui';
|
||||
@@ -33,12 +92,17 @@ import { getUserPref, setUserPref } from '../../utils/userPreferences';
|
||||
import { GuildHeaderDropdown } from './GuildHeaderDropdown';
|
||||
import { ChannelListContextMenu } from './ChannelListContextMenu';
|
||||
import { MobileServerActionsSheet } from './MobileServerActionsSheet';
|
||||
import { MobileChannelActionsSheet } from '../channel/MobileChannelActionsSheet';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { useBackHandler } from '../../hooks/useBackHandler';
|
||||
import { VoiceUserContextMenu } from '../voice/VoiceUserContextMenu';
|
||||
import { MemberProfileModal } from '../member/MemberProfileModal';
|
||||
import styles from './GuildNavbar.module.css';
|
||||
|
||||
// Custom MIME type for the voice-user drag payload. Picked so it
|
||||
// doesn't collide with generic text/plain drops (URL drags, etc).
|
||||
const VOICE_USER_DND_MIME = 'application/x-brycord-voice-user';
|
||||
|
||||
type VoiceParticipant = {
|
||||
userId: string;
|
||||
username: string;
|
||||
@@ -46,6 +110,12 @@ type VoiceParticipant = {
|
||||
isMuted?: boolean;
|
||||
isDeafened?: boolean;
|
||||
isScreenSharing?: boolean;
|
||||
/**
|
||||
* Unix ms from the voiceState row's `_creationTime` — populated by
|
||||
* `api.voiceState.getAll`. Used to render the "time in channel"
|
||||
* indicator for the viewer's own voice channel.
|
||||
*/
|
||||
joinedAt?: number;
|
||||
};
|
||||
|
||||
type ChannelDoc = {
|
||||
@@ -137,6 +207,36 @@ export function GuildNavbar() {
|
||||
const deleteChannel = useMutation(api.channels.remove);
|
||||
const renameCategory = useMutation(api.categories.rename);
|
||||
const deleteCategory = useMutation(api.categories.remove);
|
||||
const markChannelRead = useMutation(api.readState.markRead);
|
||||
const moveVoiceUser = useMutation(api.voiceState.moveUser);
|
||||
|
||||
// Permission check for the mobile "Edit Channel" action — the
|
||||
// server enforces `manage_channels` anyway, but hiding the row
|
||||
// in the drawer keeps the UX honest for viewers who can't act.
|
||||
// `move_members` gates the drag-to-move-voice-user gesture below.
|
||||
const myPerms = useQuery(
|
||||
api.roles.getMyPermissions,
|
||||
userId ? { userId: userId as any } : 'skip',
|
||||
);
|
||||
const canManageChannels = !!myPerms?.manage_channels;
|
||||
const canMoveMembers = !!myPerms?.move_members;
|
||||
|
||||
const handleMoveVoiceUser = (
|
||||
sourceUserId: string,
|
||||
targetChannelId: string,
|
||||
) => {
|
||||
if (!userId) return;
|
||||
// Server double-checks move_members and no-ops if the target
|
||||
// is already in the destination channel, so a 403 here just
|
||||
// means the permission changed between render and drop.
|
||||
void moveVoiceUser({
|
||||
actorUserId: userId as any,
|
||||
targetUserId: sourceUserId as any,
|
||||
targetChannelId: targetChannelId as any,
|
||||
}).catch((err) =>
|
||||
console.error('Failed to move voice user:', err),
|
||||
);
|
||||
};
|
||||
|
||||
type ContextMenuState =
|
||||
| {
|
||||
@@ -198,6 +298,24 @@ export function GuildNavbar() {
|
||||
const saved = getUserPref(userId, 'collapsedCategories', null);
|
||||
return Array.isArray(saved) ? new Set(saved) : new Set();
|
||||
});
|
||||
// Per-user pinned channel ids. Stored like `collapsedCategories`
|
||||
// (localStorage + optional disk via the platform settings API), so
|
||||
// pins survive refresh and sync across tabs on the same device.
|
||||
// Pinned channels remain rendered even when their parent category
|
||||
// is collapsed — that's the whole point of the feature.
|
||||
const [pinnedChannels, setPinnedChannels] = useState<Set<string>>(() => {
|
||||
const saved = getUserPref(userId, 'pinnedChannels', null);
|
||||
return Array.isArray(saved) ? new Set(saved) : new Set();
|
||||
});
|
||||
const togglePinnedChannel = (channelId: string) => {
|
||||
setPinnedChannels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(channelId)) next.delete(channelId);
|
||||
else next.add(channelId);
|
||||
setUserPref(userId, 'pinnedChannels', [...next], platform?.settings);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const headerRef = useRef<HTMLButtonElement>(null);
|
||||
const [headerDropdownRect, setHeaderDropdownRect] = useState<DOMRect | null>(null);
|
||||
const [mobileServerSheetOpen, setMobileServerSheetOpen] = useState(false);
|
||||
@@ -205,6 +323,12 @@ export function GuildNavbar() {
|
||||
{ x: number; y: number; userId: string; username: string } | null
|
||||
>(null);
|
||||
const [voiceProfileFor, setVoiceProfileFor] = useState<string | null>(null);
|
||||
// Mobile long-press on a channel row → actions sheet (Mark As
|
||||
// Read / Edit Channel). Tracks channel id + name so the sheet
|
||||
// header can show "#channel-name".
|
||||
const [mobileChannelActions, setMobileChannelActions] = useState<
|
||||
{ channelId: string; channelName: string } | null
|
||||
>(null);
|
||||
useBackHandler(mobileServerSheetOpen, () => setMobileServerSheetOpen(false));
|
||||
useBackHandler(!!voiceMenu, () => setVoiceMenu(null));
|
||||
useBackHandler(!!voiceProfileFor, () => setVoiceProfileFor(null));
|
||||
@@ -446,8 +570,29 @@ export function GuildNavbar() {
|
||||
unreadCount={unread?.unreadCount ?? 0}
|
||||
mentionCount={unread?.mentionCount ?? 0}
|
||||
participants={participants}
|
||||
isPinned={pinnedChannels.has(ch._id)}
|
||||
viewerJoinedAt={
|
||||
!isMobile
|
||||
? (participants.find(
|
||||
(p) => p.userId === userId,
|
||||
)?.joinedAt ?? null)
|
||||
: null
|
||||
}
|
||||
canMoveVoiceUsers={canMoveMembers && !isMobile}
|
||||
onMoveVoiceUserDrop={(sourceUserId) =>
|
||||
handleMoveVoiceUser(sourceUserId, ch._id)
|
||||
}
|
||||
onClick={() => handleChannelClick(ch._id)}
|
||||
onOpenSettings={() => handleOpenChannelSettings(ch._id)}
|
||||
onLongPress={
|
||||
isMobile
|
||||
? () =>
|
||||
setMobileChannelActions({
|
||||
channelId: ch._id,
|
||||
channelName: ch.name,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
onVoiceUserContextMenu={(p, e) => {
|
||||
setVoiceMenu({
|
||||
x: e.clientX,
|
||||
@@ -486,11 +631,16 @@ export function GuildNavbar() {
|
||||
{categories.map((category) => {
|
||||
const catChannels = byCategory.get(category._id) ?? [];
|
||||
const isCollapsed = collapsedCategories.has(category._id);
|
||||
// Collapsed categories still show the currently
|
||||
// Collapsed categories still show (a) the currently
|
||||
// selected channel row so the user never loses
|
||||
// their place. Matches the new UI.
|
||||
// their place, and (b) any channels the user has
|
||||
// pinned — pinning overrides collapse.
|
||||
const visibleChannels = isCollapsed
|
||||
? catChannels.filter((c) => c._id === selectedChannelId)
|
||||
? catChannels.filter(
|
||||
(c) =>
|
||||
c._id === selectedChannelId ||
|
||||
pinnedChannels.has(c._id),
|
||||
)
|
||||
: catChannels;
|
||||
return (
|
||||
<SortableCategory
|
||||
@@ -535,10 +685,33 @@ export function GuildNavbar() {
|
||||
unreadCount={unread?.unreadCount ?? 0}
|
||||
mentionCount={unread?.mentionCount ?? 0}
|
||||
participants={participants}
|
||||
isPinned={pinnedChannels.has(ch._id)}
|
||||
viewerJoinedAt={
|
||||
!isMobile
|
||||
? (participants.find(
|
||||
(p) => p.userId === userId,
|
||||
)?.joinedAt ?? null)
|
||||
: null
|
||||
}
|
||||
canMoveVoiceUsers={
|
||||
canMoveMembers && !isMobile
|
||||
}
|
||||
onMoveVoiceUserDrop={(sourceUserId) =>
|
||||
handleMoveVoiceUser(sourceUserId, ch._id)
|
||||
}
|
||||
onClick={() => handleChannelClick(ch._id)}
|
||||
onOpenSettings={() =>
|
||||
handleOpenChannelSettings(ch._id)
|
||||
}
|
||||
onLongPress={
|
||||
isMobile
|
||||
? () =>
|
||||
setMobileChannelActions({
|
||||
channelId: ch._id,
|
||||
channelName: ch.name,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
onVoiceUserContextMenu={(p, e) => {
|
||||
setVoiceMenu({
|
||||
x: e.clientX,
|
||||
@@ -616,6 +789,19 @@ export function GuildNavbar() {
|
||||
onCreateCategory={() =>
|
||||
window.dispatchEvent(new CustomEvent('brycord:open-create-category'))
|
||||
}
|
||||
onTogglePinChannel={
|
||||
contextMenu.kind === 'channel'
|
||||
? () => {
|
||||
togglePinnedChannel(contextMenu.channelId);
|
||||
setContextMenu(null);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
isPinnedChannel={
|
||||
contextMenu.kind === 'channel'
|
||||
? pinnedChannels.has(contextMenu.channelId)
|
||||
: false
|
||||
}
|
||||
onRenameChannel={
|
||||
contextMenu.kind === 'channel'
|
||||
? () => {
|
||||
@@ -679,6 +865,36 @@ export function GuildNavbar() {
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile long-press drawer for channel rows. Only renders on
|
||||
mobile; desktop users still get the hover-gear affordance. */}
|
||||
<MobileChannelActionsSheet
|
||||
isOpen={!!mobileChannelActions}
|
||||
onClose={() => setMobileChannelActions(null)}
|
||||
channelName={mobileChannelActions?.channelName ?? ''}
|
||||
canEdit={canManageChannels}
|
||||
isPinned={
|
||||
mobileChannelActions
|
||||
? pinnedChannels.has(mobileChannelActions.channelId)
|
||||
: false
|
||||
}
|
||||
onMarkAsRead={() => {
|
||||
if (!mobileChannelActions || !userId) return;
|
||||
void markChannelRead({
|
||||
userId: userId as any,
|
||||
channelId: mobileChannelActions.channelId as any,
|
||||
lastReadTimestamp: Date.now(),
|
||||
});
|
||||
}}
|
||||
onTogglePin={() => {
|
||||
if (!mobileChannelActions) return;
|
||||
togglePinnedChannel(mobileChannelActions.channelId);
|
||||
}}
|
||||
onEditChannel={() => {
|
||||
if (!mobileChannelActions) return;
|
||||
handleOpenChannelSettings(mobileChannelActions.channelId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -691,8 +907,37 @@ interface SortableChannelRowProps {
|
||||
unreadCount?: number;
|
||||
mentionCount?: number;
|
||||
participants?: VoiceParticipant[];
|
||||
/**
|
||||
* When true, renders a small pin glyph next to the channel name.
|
||||
* Pinned channels also stay visible even when their category is
|
||||
* collapsed — that filter is applied in the parent, so this flag
|
||||
* only drives the visual affordance.
|
||||
*/
|
||||
isPinned?: boolean;
|
||||
/**
|
||||
* Unix ms when the viewer joined this voice channel. Set only on
|
||||
* the channel the viewer is currently in; the row renders a live
|
||||
* H:MM:SS / MM:SS counter at the gear's position when provided.
|
||||
* Mobile callers pass `undefined` so the counter never shows there.
|
||||
*/
|
||||
viewerJoinedAt?: number | null;
|
||||
/**
|
||||
* When true, voice participants in this row are draggable and the
|
||||
* row accepts drops of other voice users. Gated on `move_members`
|
||||
* in the parent; undefined / false disables the whole gesture.
|
||||
*/
|
||||
canMoveVoiceUsers?: boolean;
|
||||
/** Drop handler — receives the dragged user's id. */
|
||||
onMoveVoiceUserDrop?: (sourceUserId: string) => void;
|
||||
onClick: () => void;
|
||||
onOpenSettings: () => void;
|
||||
/**
|
||||
* Mobile long-press handler — wire to open the
|
||||
* `MobileChannelActionsSheet`. The row owns the 450 ms timer and
|
||||
* cancels on finger move / up so a scroll-gesture still works.
|
||||
* Omit on desktop to skip touch wiring entirely.
|
||||
*/
|
||||
onLongPress?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
onVoiceUserContextMenu?: (
|
||||
participant: VoiceParticipant,
|
||||
@@ -708,8 +953,13 @@ function SortableChannelRow({
|
||||
unreadCount = 0,
|
||||
mentionCount = 0,
|
||||
participants = [],
|
||||
isPinned = false,
|
||||
viewerJoinedAt = null,
|
||||
canMoveVoiceUsers = false,
|
||||
onMoveVoiceUserDrop,
|
||||
onClick,
|
||||
onOpenSettings,
|
||||
onLongPress,
|
||||
onContextMenu,
|
||||
onVoiceUserContextMenu,
|
||||
}: SortableChannelRowProps) {
|
||||
@@ -724,28 +974,140 @@ function SortableChannelRow({
|
||||
const hasUnread = unreadCount > 0 && !selected;
|
||||
const hasMention = mentionCount > 0;
|
||||
const showParticipants = isVoice && participants.length > 0;
|
||||
|
||||
// Live clock for the "time in channel" indicator. `useState` driven
|
||||
// by a 1-second interval that only runs when `viewerJoinedAt` is
|
||||
// set (i.e. the viewer is actually in this channel) — idle rows
|
||||
// pay no re-render cost. The formatter collapses hours when the
|
||||
// session is under an hour so short sessions read "12:34" instead
|
||||
// of "0:12:34".
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (!viewerJoinedAt) return;
|
||||
setNow(Date.now());
|
||||
const id = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [viewerJoinedAt]);
|
||||
|
||||
const formatDuration = (elapsedMs: number): string => {
|
||||
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
|
||||
const h = Math.floor(totalSeconds / 3600);
|
||||
const m = Math.floor((totalSeconds % 3600) / 60);
|
||||
const s = totalSeconds % 60;
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||
};
|
||||
|
||||
// 450 ms long-press → open the mobile channel actions sheet. Same
|
||||
// timing as the message long-press so the muscle memory carries
|
||||
// over. Finger movement / lift cancels before the timer fires so
|
||||
// a scroll-gesture through the sidebar still works.
|
||||
const longPressTimerRef = useRef<number | null>(null);
|
||||
const firedLongPressRef = useRef(false);
|
||||
|
||||
// Native HTML5 DnD state for "drag a voice user into this channel"
|
||||
// — only tracked on voice rows that are accepting drops.
|
||||
const [isDropTarget, setIsDropTarget] = useState(false);
|
||||
const acceptsVoiceDrop = isVoice && canMoveVoiceUsers && !!onMoveVoiceUserDrop;
|
||||
const handleWrapperDragOver = (e: React.DragEvent) => {
|
||||
if (!acceptsVoiceDrop) return;
|
||||
if (!e.dataTransfer.types.includes(VOICE_USER_DND_MIME)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (!isDropTarget) setIsDropTarget(true);
|
||||
};
|
||||
const handleWrapperDragLeave = (e: React.DragEvent) => {
|
||||
if (!acceptsVoiceDrop) return;
|
||||
// `dragleave` fires on children too — only clear when leaving
|
||||
// the wrapper itself. `relatedTarget` is the element the
|
||||
// pointer entered; if it's still inside `currentTarget`, we're
|
||||
// still hovering the drop zone.
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (next && e.currentTarget.contains(next)) return;
|
||||
setIsDropTarget(false);
|
||||
};
|
||||
const handleWrapperDrop = (e: React.DragEvent) => {
|
||||
if (!acceptsVoiceDrop) return;
|
||||
const payload = e.dataTransfer.getData(VOICE_USER_DND_MIME);
|
||||
if (!payload) return;
|
||||
e.preventDefault();
|
||||
setIsDropTarget(false);
|
||||
onMoveVoiceUserDrop?.(payload);
|
||||
};
|
||||
const startLongPress = () => {
|
||||
if (!onLongPress) return;
|
||||
firedLongPressRef.current = false;
|
||||
if (longPressTimerRef.current !== null) {
|
||||
window.clearTimeout(longPressTimerRef.current);
|
||||
}
|
||||
longPressTimerRef.current = window.setTimeout(() => {
|
||||
firedLongPressRef.current = true;
|
||||
onLongPress();
|
||||
}, 450);
|
||||
};
|
||||
const cancelLongPress = () => {
|
||||
if (longPressTimerRef.current !== null) {
|
||||
window.clearTimeout(longPressTimerRef.current);
|
||||
longPressTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`${styles.channelItemWrapper} ${hasUnread ? styles.channelItemUnread : ''}`}
|
||||
className={`${styles.channelItemWrapper} ${hasUnread ? styles.channelItemUnread : ''} ${isDropTarget ? styles.channelItemDropTarget : ''}`}
|
||||
data-selected={selected ? 'true' : undefined}
|
||||
data-unread={hasUnread ? 'true' : undefined}
|
||||
data-drop-target={isDropTarget ? 'true' : undefined}
|
||||
onContextMenu={onContextMenu}
|
||||
onTouchStart={onLongPress ? startLongPress : undefined}
|
||||
onTouchEnd={onLongPress ? cancelLongPress : undefined}
|
||||
onTouchMove={onLongPress ? cancelLongPress : undefined}
|
||||
onTouchCancel={onLongPress ? cancelLongPress : undefined}
|
||||
onDragOver={acceptsVoiceDrop ? handleWrapperDragOver : undefined}
|
||||
onDragLeave={acceptsVoiceDrop ? handleWrapperDragLeave : undefined}
|
||||
onDrop={acceptsVoiceDrop ? handleWrapperDrop : undefined}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.channelItem} ${selected ? styles.channelSelected : ''}`}
|
||||
onClick={onClick}
|
||||
onClick={(e) => {
|
||||
// Swallow the click that would otherwise fire
|
||||
// immediately after a long-press-triggered sheet
|
||||
// opens and navigate the user into the channel.
|
||||
if (firedLongPressRef.current) {
|
||||
firedLongPressRef.current = false;
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
{isVoice ? (
|
||||
<SpeakerHigh size={20} className={styles.channelIcon} />
|
||||
) : (
|
||||
<Hash size={20} weight="bold" className={styles.channelIcon} />
|
||||
)}
|
||||
{(() => {
|
||||
// Green-tint the voice glyph whenever anyone is
|
||||
// currently connected — makes a live voice channel
|
||||
// pop in the sidebar at a glance.
|
||||
const voiceActive = isVoice && participants.length > 0;
|
||||
const iconClass = `${styles.channelIcon} ${
|
||||
voiceActive ? styles.channelIconVoiceActive : ''
|
||||
}`;
|
||||
if (isVoice) {
|
||||
return isPinned ? (
|
||||
<VoicePinnedIcon className={iconClass} />
|
||||
) : (
|
||||
<SpeakerHigh size={20} className={iconClass} />
|
||||
);
|
||||
}
|
||||
return isPinned ? (
|
||||
<HashtagPinnedIcon className={iconClass} />
|
||||
) : (
|
||||
<Hash size={20} weight="bold" className={iconClass} />
|
||||
);
|
||||
})()}
|
||||
<span
|
||||
className={`${styles.channelName} ${hasUnread ? styles.channelUnread : ''}`}
|
||||
>
|
||||
@@ -757,6 +1119,15 @@ function SortableChannelRow({
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{viewerJoinedAt && (
|
||||
<span
|
||||
className={styles.channelVoiceTimer}
|
||||
aria-label="Time in voice channel"
|
||||
title="Time in voice channel"
|
||||
>
|
||||
{formatDuration(now - viewerJoinedAt)}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.channelSettingsGear}
|
||||
@@ -774,12 +1145,31 @@ function SortableChannelRow({
|
||||
<div
|
||||
className={styles.voiceUsers}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
// Mirror the wrapper drop handlers so users can drop
|
||||
// directly onto the participant list too — not just
|
||||
// the channel header row.
|
||||
onDragOver={acceptsVoiceDrop ? handleWrapperDragOver : undefined}
|
||||
onDragLeave={acceptsVoiceDrop ? handleWrapperDragLeave : undefined}
|
||||
onDrop={acceptsVoiceDrop ? handleWrapperDrop : undefined}
|
||||
>
|
||||
{participants.map((p) => (
|
||||
<div
|
||||
key={p.userId}
|
||||
className={styles.voiceUser}
|
||||
title={p.username}
|
||||
draggable={canMoveVoiceUsers}
|
||||
onDragStart={
|
||||
canMoveVoiceUsers
|
||||
? (e) => {
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.setData(
|
||||
VOICE_USER_DND_MIME,
|
||||
p.userId,
|
||||
);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
if (!onVoiceUserContextMenu) return;
|
||||
e.preventDefault();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -60,6 +60,24 @@
|
||||
color: var(--voice-text-strong);
|
||||
}
|
||||
|
||||
.backButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--voice-text-strong);
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.backButton:active {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.channelIcon {
|
||||
color: var(--voice-text-muted);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Hash } from '@phosphor-icons/react';
|
||||
import { ArrowLeft, Hash } from '@phosphor-icons/react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useVoice } from '../../contexts/VoiceContext';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { ScreenSharePreview } from './ScreenSharePreview';
|
||||
import { VoiceGridLayout } from './VoiceGridLayout';
|
||||
import { VoiceControlBar } from './VoiceControlBar';
|
||||
@@ -22,6 +24,21 @@ interface RawParticipant {
|
||||
|
||||
export function VoiceCallView({ channelId }: VoiceCallViewProps) {
|
||||
const voice = useVoice();
|
||||
const isMobile = useIsMobile();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// On mobile the call surface fills the whole viewport and the
|
||||
// channel list isn't visible, so expose an explicit way back.
|
||||
// Drop the last route segment (the channelId) so we land on the
|
||||
// server's nav column (e.g. /channels/home/xyz → /channels/home),
|
||||
// which `GuildsLayout` renders as the sidebar on mobile.
|
||||
const handleBack = () => {
|
||||
const segments = location.pathname.split('/').filter(Boolean);
|
||||
if (segments[0] === 'channels' && segments.length >= 3) {
|
||||
navigate(`/${segments[0]}/${segments[1]}`);
|
||||
}
|
||||
};
|
||||
|
||||
const rawParticipants: RawParticipant[] =
|
||||
(voice?.voiceStates && voice.voiceStates[channelId]) || [];
|
||||
@@ -42,6 +59,16 @@ export function VoiceCallView({ channelId }: VoiceCallViewProps) {
|
||||
<div className={`${styles.root} ${styles.pointerActive}`} data-voice-call-root>
|
||||
<div className={styles.voiceHeader}>
|
||||
<div className={styles.headerLeftSection}>
|
||||
{isMobile && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backButton}
|
||||
onClick={handleBack}
|
||||
aria-label="Back to channel list"
|
||||
>
|
||||
<ArrowLeft size={22} weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
<div className={styles.channelInfoContainer}>
|
||||
<Hash size={20} weight="bold" className={styles.channelIcon} />
|
||||
<span className={styles.channelName}>{channelName}</span>
|
||||
|
||||
@@ -82,6 +82,11 @@ export const VoiceProvider = ({ children }) => {
|
||||
);
|
||||
const isMovingRef = useRef(false);
|
||||
const isDMCallRef = useRef(false);
|
||||
// Flipped to true by `disconnectVoice` when the user presses
|
||||
// disconnect *during* the 'connecting' phase. Each major await in
|
||||
// `connectToVoice` checks this ref and bails out cleanly so we
|
||||
// don't end up connected to a channel the user tried to leave.
|
||||
const cancelConnectRef = useRef(false);
|
||||
const lastSpokeRef = useRef(Date.now());
|
||||
const [isReceivingScreenShareAudio, setIsReceivingScreenShareAudio] = useState(false);
|
||||
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||
@@ -288,6 +293,20 @@ export const VoiceProvider = ({ children }) => {
|
||||
const connectToVoice = async (channelId, channelName, userId, isDMCall = false) => {
|
||||
if (activeChannelId === channelId) return;
|
||||
isDMCallRef.current = isDMCall;
|
||||
cancelConnectRef.current = false;
|
||||
|
||||
// Channel hop — we're already in a voice room. Flip the
|
||||
// move-in-progress flag BEFORE disconnecting so the old
|
||||
// room's RoomEvent.Disconnected handler takes the short
|
||||
// "moving" branch: it clears room/token without touching
|
||||
// activeChannelId, skips the voiceState.leave mutation, and
|
||||
// skips the leave sound. Without this the handler races
|
||||
// against the rest of this function — nulling the channel
|
||||
// id we're about to set and calling leave() between our
|
||||
// disconnect and the new voiceState.join, which looks like
|
||||
// "joins, plays sound, drops out" from the user's side.
|
||||
const isMove = !!room;
|
||||
if (isMove) isMovingRef.current = true;
|
||||
|
||||
if (room) await room.disconnect();
|
||||
|
||||
@@ -296,6 +315,38 @@ export const VoiceProvider = ({ children }) => {
|
||||
setConnectionState('connecting');
|
||||
window.__inVoiceCall = true;
|
||||
|
||||
// Cancellation check — called after every major async step.
|
||||
// If the user pressed disconnect while we were awaiting, roll
|
||||
// back: close the partially-built room (if any; its
|
||||
// Disconnected handler cleans up state), otherwise reset the
|
||||
// pre-room UI state manually. Returns true when the caller
|
||||
// should bail.
|
||||
const abortIfCancelled = (builtRoom) => {
|
||||
if (!cancelConnectRef.current) return false;
|
||||
if (builtRoom) {
|
||||
builtRoom.disconnect().catch(() => {});
|
||||
}
|
||||
// Always reset UI state on cancel, even when a room is
|
||||
// being torn down. The Disconnected handler's "moving"
|
||||
// branch preserves activeChannelId (needed for legit
|
||||
// moves), so we explicitly clear it here.
|
||||
setActiveChannelId(null);
|
||||
setActiveChannelName(null);
|
||||
setConnectionState('disconnected');
|
||||
setToken(null);
|
||||
window.__inVoiceCall = false;
|
||||
voiceService?.stopService();
|
||||
// If this was a channel hop we flipped isMovingRef on;
|
||||
// reset it so a later disconnect doesn't wrongly skip
|
||||
// voiceState.leave.
|
||||
isMovingRef.current = false;
|
||||
// Clean up any stale server-side voice state (the old
|
||||
// channel's row survives a cancelled move because the
|
||||
// "moving" branch skips voiceState.leave).
|
||||
convex.mutation(api.voiceState.leave, { userId }).catch(() => {});
|
||||
return true;
|
||||
};
|
||||
|
||||
try {
|
||||
// Request microphone permission (triggers Android runtime prompt)
|
||||
try {
|
||||
@@ -308,6 +359,8 @@ export const VoiceProvider = ({ children }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (abortIfCancelled(null)) return;
|
||||
|
||||
// Prove we control `userId` by signing the (userId, channelId,
|
||||
// timestamp) tuple with the Ed25519 key decrypted at login.
|
||||
// The server verifies with our public signing key before minting
|
||||
@@ -323,6 +376,8 @@ export const VoiceProvider = ({ children }) => {
|
||||
const message = `voice-token:${userId}:${channelId}:${timestamp}`;
|
||||
const signature = await platform.crypto.signMessage(signingKey, message);
|
||||
|
||||
if (abortIfCancelled(null)) return;
|
||||
|
||||
const tokenResult = await convex.action(api.voice.getToken, {
|
||||
channelId,
|
||||
userId,
|
||||
@@ -337,6 +392,8 @@ export const VoiceProvider = ({ children }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (abortIfCancelled(null)) return;
|
||||
|
||||
const lkToken = tokenResult.token;
|
||||
if (!lkToken) throw new Error('Failed to get token');
|
||||
|
||||
@@ -411,6 +468,10 @@ export const VoiceProvider = ({ children }) => {
|
||||
});
|
||||
await newRoom.connect(import.meta.env.VITE_LIVEKIT_URL, lkToken);
|
||||
|
||||
// Cancelled during the LiveKit handshake — tear the room
|
||||
// back down before we wire any more state around it.
|
||||
if (abortIfCancelled(newRoom)) return;
|
||||
|
||||
if (storedOutputDevice && storedOutputDevice !== 'default') {
|
||||
await newRoom.switchActiveDevice('audiooutput', storedOutputDevice).catch(e =>
|
||||
console.warn('Failed to switch output device on connect:', e)
|
||||
@@ -419,6 +480,8 @@ export const VoiceProvider = ({ children }) => {
|
||||
|
||||
await newRoom.localParticipant.setMicrophoneEnabled(!isMuted && !isDeafened);
|
||||
|
||||
if (abortIfCancelled(newRoom)) return;
|
||||
|
||||
setRoom(newRoom);
|
||||
setConnectionState('connected');
|
||||
// Start native foreground service for background voice on Android
|
||||
@@ -430,22 +493,12 @@ export const VoiceProvider = ({ children }) => {
|
||||
playSound('join');
|
||||
}
|
||||
|
||||
await convex.mutation(api.voiceState.join, {
|
||||
channelId,
|
||||
userId,
|
||||
username: localStorage.getItem('username') || 'Unknown',
|
||||
isMuted,
|
||||
isDeafened,
|
||||
});
|
||||
|
||||
// Auto-mute when joining AFK channel
|
||||
if (serverSettings?.afkChannelId === channelId) {
|
||||
setIsMuted(true);
|
||||
await newRoom.localParticipant.setMicrophoneEnabled(false);
|
||||
await convex.mutation(api.voiceState.updateState, { userId, isMuted: true });
|
||||
voiceService?.updateNotification({ isMuted: true });
|
||||
}
|
||||
|
||||
// Register the Disconnected / Reconnecting / etc. handlers
|
||||
// BEFORE `voiceState.join`. If the user taps disconnect in
|
||||
// the narrow window between `setRoom` and this mutation
|
||||
// returning, `disconnectVoice` calls `room.disconnect()` —
|
||||
// without the handler bound there's nothing to tear down
|
||||
// state. Keeping registration first closes that gap.
|
||||
newRoom.on(RoomEvent.Disconnected, async (reason) => {
|
||||
console.warn('Voice Room Disconnected. Reason:', reason);
|
||||
// If we're being moved, skip leave mutation — we'll reconnect shortly
|
||||
@@ -556,11 +609,46 @@ export const VoiceProvider = ({ children }) => {
|
||||
}));
|
||||
});
|
||||
|
||||
// Commit the server-side voice state LAST. Until this
|
||||
// resolves the reactive `voiceStates` query still reports
|
||||
// us in the old channel, which is why we keep
|
||||
// `isMovingRef.current === true` across the mutation —
|
||||
// otherwise the reconcile effect below sees "server says
|
||||
// A, client says B" and kicks off a spurious reconnect
|
||||
// back to A (the bug that caused double-join / double-
|
||||
// leave sounds when switching channels).
|
||||
await convex.mutation(api.voiceState.join, {
|
||||
channelId,
|
||||
userId,
|
||||
username: localStorage.getItem('username') || 'Unknown',
|
||||
isMuted,
|
||||
isDeafened,
|
||||
});
|
||||
|
||||
// Auto-mute when joining AFK channel
|
||||
if (serverSettings?.afkChannelId === channelId) {
|
||||
setIsMuted(true);
|
||||
await newRoom.localParticipant.setMicrophoneEnabled(false);
|
||||
await convex.mutation(api.voiceState.updateState, { userId, isMuted: true });
|
||||
voiceService?.updateNotification({ isMuted: true });
|
||||
}
|
||||
|
||||
// Now that the server state has been committed and the
|
||||
// reactive query will (imminently) show us in `channelId`,
|
||||
// it's safe to re-enable the reconcile effect. A subtle
|
||||
// point: the query push can land either before or after
|
||||
// the mutation promise resolves; both orderings are fine
|
||||
// because in either case by the time the effect next
|
||||
// reads `voiceStates` it'll either agree with
|
||||
// activeChannelId or this flag will still be true.
|
||||
isMovingRef.current = false;
|
||||
|
||||
} catch (err) {
|
||||
console.error('Voice Connection Failed:', err);
|
||||
setConnectionState('error');
|
||||
setActiveChannelId(null);
|
||||
window.__inVoiceCall = false;
|
||||
isMovingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -975,6 +1063,38 @@ export const VoiceProvider = ({ children }) => {
|
||||
const disconnectVoice = () => {
|
||||
console.log('User manually disconnected voice');
|
||||
isDMCallRef.current = false;
|
||||
|
||||
// Mid-connect cancel: `connectToVoice` is awaiting somewhere
|
||||
// (mic permission, Convex token action, LiveKit handshake).
|
||||
// Flip the cancel ref so the next `abortIfCancelled` check
|
||||
// tears things down, and reset visible UI state immediately
|
||||
// so the button reflects the user's intent even before the
|
||||
// in-flight awaits unwind.
|
||||
if (connectionState === 'connecting') {
|
||||
cancelConnectRef.current = true;
|
||||
setConnectionState('disconnected');
|
||||
setActiveChannelId(null);
|
||||
setActiveChannelName(null);
|
||||
setToken(null);
|
||||
window.__inVoiceCall = false;
|
||||
voiceService?.stopService();
|
||||
// If the LiveKit connect already completed before the
|
||||
// cancel fired, the room exists in state and we still
|
||||
// need to drop it. The RoomEvent.Disconnected handler
|
||||
// will run voiceState.leave and finish cleanup.
|
||||
if (room) room.disconnect().catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// A user-initiated disconnect always wants the "leave for
|
||||
// real" path, even if we're still inside the
|
||||
// `connectToVoice(new)` move window (connectionState ===
|
||||
// 'connected' but `isMovingRef` hasn't cleared yet because
|
||||
// `voiceState.join` is in flight). Clearing the move flag
|
||||
// here makes the Disconnected handler take the normal
|
||||
// branch: play leave, clear activeChannelId, run
|
||||
// voiceState.leave.
|
||||
isMovingRef.current = false;
|
||||
window.__inVoiceCall = false;
|
||||
voiceService?.stopService();
|
||||
if (room) room.disconnect();
|
||||
|
||||
Reference in New Issue
Block a user