feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
- Implemented Button component with various props for customization. - Created Modal component with header, content, and footer subcomponents. - Added Spinner component for loading indicators. - Developed Toast component for displaying notifications. - Introduced Tooltip component for contextual hints with keyboard shortcuts. - Added corresponding CSS modules for styling each component. - Updated index file to export new components. - Configured TypeScript settings for the UI package.
This commit is contained in:
@@ -1,18 +1,24 @@
|
||||
{
|
||||
"name": "@discord-clone/shared",
|
||||
"private": true,
|
||||
"version": "1.0.40",
|
||||
"version": "1.0.50",
|
||||
"type": "module",
|
||||
"main": "src/App.jsx",
|
||||
"main": "src/App.tsx",
|
||||
"dependencies": {
|
||||
"@convex-dev/presence": "^0.3.0",
|
||||
"@discord-clone/ui": "*",
|
||||
"@discord-clone/constants": "*",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@floating-ui/react": "^0.26.0",
|
||||
"@livekit/components-react": "^2.9.17",
|
||||
"@livekit/components-styles": "^1.2.0",
|
||||
"@phosphor-icons/react": "^2.1.7",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"clsx": "^2.1.1",
|
||||
"convex": "^1.31.2",
|
||||
"framer-motion": "^11.0.0",
|
||||
"livekit-client": "^2.16.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
@@ -23,5 +29,10 @@
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sql.js": "^1.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import Recovery from './pages/Recovery';
|
||||
import Chat from './pages/Chat';
|
||||
import { usePlatform } from './platform';
|
||||
import { useSearch } from './contexts/SearchContext';
|
||||
import { useSystemBars } from './hooks/useSystemBars';
|
||||
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function AuthGuard({ children }) {
|
||||
const [authState, setAuthState] = useState('loading'); // 'loading' | 'authenticated' | 'unauthenticated'
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { session, settings } = usePlatform();
|
||||
const searchCtx = useSearch();
|
||||
useSystemBars(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function restoreSession() {
|
||||
// Already have keys in sessionStorage — current session is active
|
||||
if (sessionStorage.getItem('privateKey') && sessionStorage.getItem('signingKey')) {
|
||||
searchCtx?.initialize();
|
||||
if (!cancelled) setAuthState('authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
// Try restoring from safeStorage
|
||||
if (session) {
|
||||
try {
|
||||
const savedSession = await session.load();
|
||||
if (savedSession && savedSession.savedAt && (Date.now() - savedSession.savedAt) < THIRTY_DAYS_MS) {
|
||||
// Restore to localStorage + sessionStorage
|
||||
localStorage.setItem('userId', savedSession.userId);
|
||||
localStorage.setItem('username', savedSession.username);
|
||||
if (savedSession.publicKey) localStorage.setItem('publicKey', savedSession.publicKey);
|
||||
sessionStorage.setItem('signingKey', savedSession.signingKey);
|
||||
sessionStorage.setItem('privateKey', savedSession.privateKey);
|
||||
if (savedSession.masterKey) sessionStorage.setItem('masterKey', savedSession.masterKey);
|
||||
if (savedSession.searchDbKey) sessionStorage.setItem('searchDbKey', savedSession.searchDbKey);
|
||||
searchCtx?.initialize();
|
||||
// Restore user preferences from file-based backup into localStorage
|
||||
if (settings) {
|
||||
try {
|
||||
const savedPrefs = await settings.get(`userPrefs_${savedSession.userId}`);
|
||||
if (savedPrefs && typeof savedPrefs === 'object') {
|
||||
localStorage.setItem(`userPrefs_${savedSession.userId}`, JSON.stringify(savedPrefs));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (!cancelled) setAuthState('authenticated');
|
||||
return;
|
||||
}
|
||||
// Expired — clear stale session
|
||||
if (savedSession && savedSession.savedAt) {
|
||||
await session.clear();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Session restore failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) setAuthState('unauthenticated');
|
||||
}
|
||||
|
||||
restoreSession();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (authState === 'loading') return;
|
||||
|
||||
const isAuthPage = location.pathname === '/' || location.pathname === '/register' || location.pathname === '/recovery';
|
||||
const hasSession = sessionStorage.getItem('privateKey') && sessionStorage.getItem('signingKey');
|
||||
|
||||
if (hasSession && isAuthPage) {
|
||||
navigate('/chat', { replace: true });
|
||||
} else if (!hasSession && !isAuthPage) {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}, [authState, location.pathname]);
|
||||
|
||||
if (authState === 'loading') {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
backgroundColor: 'var(--bg-primary, #313338)',
|
||||
color: 'var(--text-normal, #dbdee1)',
|
||||
fontSize: '16px',
|
||||
}}>
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<Routes>
|
||||
<Route path="/" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/recovery" element={<Recovery />} />
|
||||
<Route path="/chat" element={<Chat />} />
|
||||
</Routes>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
181
packages/shared/src/App.tsx
Normal file
181
packages/shared/src/App.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { IconContext } from '@phosphor-icons/react';
|
||||
import { LoginPage } from './components/auth/LoginPage';
|
||||
import { RegisterPage } from './components/auth/RegisterPage';
|
||||
import { InviteAcceptPage } from './components/auth/InviteAcceptPage';
|
||||
import { AppLayout } from './components/layout/AppLayout';
|
||||
import { MobileYouPage } from './components/layout/MobileYouPage';
|
||||
import { TitleBar } from './components/layout/TitleBar';
|
||||
import { ChannelView } from './components/channel/ChannelView';
|
||||
import Recovery from './pages/Recovery';
|
||||
import { usePlatform } from './platform';
|
||||
import { useSearch } from './contexts/SearchContext';
|
||||
import { useSystemBars } from './hooks/useSystemBars';
|
||||
import './global.css';
|
||||
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
type AuthState = 'loading' | 'authenticated' | 'unauthenticated';
|
||||
|
||||
function AuthGuard({ children }: { children: ReactNode }) {
|
||||
const [authState, setAuthState] = useState<AuthState>('loading');
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { session, settings } = usePlatform();
|
||||
const searchCtx = useSearch();
|
||||
useSystemBars(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function restoreSession() {
|
||||
if (sessionStorage.getItem('privateKey') && sessionStorage.getItem('signingKey')) {
|
||||
searchCtx?.initialize();
|
||||
if (!cancelled) setAuthState('authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
if (session) {
|
||||
try {
|
||||
const savedSession = await session.load();
|
||||
if (savedSession && savedSession.savedAt && Date.now() - savedSession.savedAt < THIRTY_DAYS_MS) {
|
||||
localStorage.setItem('userId', savedSession.userId);
|
||||
localStorage.setItem('username', savedSession.username);
|
||||
if (savedSession.publicKey) localStorage.setItem('publicKey', savedSession.publicKey);
|
||||
sessionStorage.setItem('signingKey', savedSession.signingKey);
|
||||
sessionStorage.setItem('privateKey', savedSession.privateKey);
|
||||
if (savedSession.masterKey) sessionStorage.setItem('masterKey', savedSession.masterKey);
|
||||
if (savedSession.searchDbKey) sessionStorage.setItem('searchDbKey', savedSession.searchDbKey);
|
||||
searchCtx?.initialize();
|
||||
if (settings) {
|
||||
try {
|
||||
const savedPrefs = await settings.get(`userPrefs_${savedSession.userId}`);
|
||||
if (savedPrefs && typeof savedPrefs === 'object') {
|
||||
localStorage.setItem(`userPrefs_${savedSession.userId}`, JSON.stringify(savedPrefs));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (!cancelled) setAuthState('authenticated');
|
||||
return;
|
||||
}
|
||||
if (savedSession?.savedAt) {
|
||||
await session.clear();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Session restore failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) setAuthState('unauthenticated');
|
||||
}
|
||||
|
||||
restoreSession();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (authState === 'loading') return;
|
||||
|
||||
const isAuthPage =
|
||||
location.pathname === '/login' ||
|
||||
location.pathname === '/register' ||
|
||||
location.pathname === '/recovery' ||
|
||||
location.pathname.startsWith('/invite/');
|
||||
const hasSession = !!(sessionStorage.getItem('privateKey') && sessionStorage.getItem('signingKey'));
|
||||
|
||||
// Invite accept runs regardless of session — if the viewer is
|
||||
// already logged in, the InviteAcceptPage itself will forward
|
||||
// them home after stashing the keys. Don't redirect them away.
|
||||
const isInvitePage = location.pathname.startsWith('/invite/');
|
||||
|
||||
if (hasSession && isAuthPage && !isInvitePage) {
|
||||
navigate('/channels/@me', { replace: true });
|
||||
} else if (!hasSession && !isAuthPage) {
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
}, [authState, location.pathname]);
|
||||
|
||||
if (authState === 'loading') {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
backgroundColor: 'var(--background-primary)',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function DMHomePage() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: 1,
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h2 style={{ color: 'var(--text-primary)', marginBottom: 8 }}>Welcome to Brycord</h2>
|
||||
<p>Select a conversation or start a new one</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServerPage() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: 1,
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
<p>Select a channel to start chatting</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<IconContext.Provider value={{ color: 'currentColor', weight: 'fill' }}>
|
||||
<TitleBar />
|
||||
<AuthGuard>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/invite/:code" element={<InviteAcceptPage />} />
|
||||
<Route path="/recovery" element={<Recovery />} />
|
||||
<Route path="/channels/*" element={<AppLayout />}>
|
||||
<Route path="@me" element={<DMHomePage />} />
|
||||
<Route path="@me/:channelId" element={<ChannelView />} />
|
||||
<Route path="you" element={<MobileYouPage />} />
|
||||
<Route path=":serverId" element={<ServerPage />} />
|
||||
<Route path=":serverId/:channelId" element={<ChannelView />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/channels/@me" replace />} />
|
||||
</Routes>
|
||||
</AuthGuard>
|
||||
</IconContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
13
packages/shared/src/_shims/actions/MatrixActions.ts
Normal file
13
packages/shared/src/_shims/actions/MatrixActions.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* MatrixActions shim — the original wired Matrix SDK event listeners.
|
||||
* In this project, reactive state comes from Convex useQuery, so these
|
||||
* are no-ops kept only to satisfy imports.
|
||||
*/
|
||||
|
||||
export const initializeMatrix = async (): Promise<void> => {};
|
||||
export const teardownMatrix = async (): Promise<void> => {};
|
||||
|
||||
export default {
|
||||
initializeMatrix,
|
||||
teardownMatrix,
|
||||
};
|
||||
29
packages/shared/src/_shims/actions/MessageActionCreators.ts
Normal file
29
packages/shared/src/_shims/actions/MessageActionCreators.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* MessageActionCreators shim — stub message action flows.
|
||||
* Real sends go through Convex; these no-ops keep the UI compiling
|
||||
* until each call site is rewired.
|
||||
*/
|
||||
|
||||
import type { Attachment } from '../matrix-client';
|
||||
|
||||
export interface SendMessageArgs {
|
||||
channelId: string;
|
||||
body: string;
|
||||
formattedBody?: string;
|
||||
replyToId?: string | null;
|
||||
attachments?: Attachment[];
|
||||
mentions?: string[];
|
||||
}
|
||||
|
||||
export const MessageActionCreators = {
|
||||
sendMessage: async (_args: SendMessageArgs): Promise<string> => '',
|
||||
editMessage: async (_channelId: string, _messageId: string, _body: string) => {},
|
||||
deleteMessage: async (_channelId: string, _messageId: string) => {},
|
||||
addReaction: async (_channelId: string, _messageId: string, _key: string) => {},
|
||||
removeReaction: async (_channelId: string, _messageId: string, _key: string) => {},
|
||||
markRead: async (_channelId: string, _messageId: string) => {},
|
||||
flushPendingAttachments: async (_channelId: string) => {},
|
||||
createPoll: async (_args: unknown) => {},
|
||||
};
|
||||
|
||||
export default MessageActionCreators;
|
||||
2
packages/shared/src/_shims/actions/index.ts
Normal file
2
packages/shared/src/_shims/actions/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './MessageActionCreators';
|
||||
export * from './MatrixActions';
|
||||
119
packages/shared/src/_shims/app/data/emoji-shortcuts.json
Normal file
119
packages/shared/src/_shims/app/data/emoji-shortcuts.json
Normal file
@@ -0,0 +1,119 @@
|
||||
[
|
||||
{
|
||||
"emoji": "angry",
|
||||
"shortcuts": [">:(", ">:-(", ">=(", ">=-("]
|
||||
},
|
||||
{
|
||||
"emoji": "blush",
|
||||
"shortcuts": [":\")", ":-\")", "=\")", "=-\")"]
|
||||
},
|
||||
{
|
||||
"emoji": "broken_heart",
|
||||
"shortcuts": ["</3", "<\\3"]
|
||||
},
|
||||
{
|
||||
"emoji": "confused",
|
||||
"shortcuts": [":-\\", ":-/", "=-\\", "=-/"]
|
||||
},
|
||||
{
|
||||
"emoji": "cry",
|
||||
"shortcuts": [":'(", ":'-(", ":,(", ":,-(", "='(", "='-(", "=,(", "=,-("]
|
||||
},
|
||||
{
|
||||
"emoji": "frowning",
|
||||
"shortcuts": [":(", ":-(", "=(", "=-("]
|
||||
},
|
||||
{
|
||||
"emoji": "heart",
|
||||
"shortcuts": ["<3", "♡"]
|
||||
},
|
||||
{
|
||||
"emoji": "imp",
|
||||
"shortcuts": ["]:(", "]:-(", "]=(", "]=-("]
|
||||
},
|
||||
{
|
||||
"emoji": "innocent",
|
||||
"shortcuts": ["o:)", "O:)", "o:-)", "O:-)", "0:)", "0:-)", "o=)", "O=)", "o=-)", "O=-)", "0=)", "0=-)"]
|
||||
},
|
||||
{
|
||||
"emoji": "joy",
|
||||
"shortcuts": [
|
||||
":')",
|
||||
":'-)",
|
||||
":,)",
|
||||
":,-)",
|
||||
":'D",
|
||||
":'-D",
|
||||
":,D",
|
||||
":,-D",
|
||||
"=')",
|
||||
"='-)",
|
||||
"=,)",
|
||||
"=,-)",
|
||||
"='D",
|
||||
"='-D",
|
||||
"=,D",
|
||||
"=,-D"
|
||||
]
|
||||
},
|
||||
{
|
||||
"emoji": "kissing",
|
||||
"shortcuts": [":*", ":-*", "=*", "=-*"]
|
||||
},
|
||||
{
|
||||
"emoji": "laughing",
|
||||
"shortcuts": ["x-)", "X-)"]
|
||||
},
|
||||
{
|
||||
"emoji": "neutral_face",
|
||||
"shortcuts": [":|", ":-|", "=|", "=-|"]
|
||||
},
|
||||
{
|
||||
"emoji": "open_mouth",
|
||||
"shortcuts": [":o", ":-o", ":O", ":-O", "=o", "=-o", "=O", "=-O"]
|
||||
},
|
||||
{
|
||||
"emoji": "rage",
|
||||
"shortcuts": [":@", ":-@", "=@", "=-@"]
|
||||
},
|
||||
{
|
||||
"emoji": "smile",
|
||||
"shortcuts": [":D", ":-D", "=D", "=-D"]
|
||||
},
|
||||
{
|
||||
"emoji": "smiley",
|
||||
"shortcuts": [":)", ":-)", "=)", "=-)"]
|
||||
},
|
||||
{
|
||||
"emoji": "smiling_imp",
|
||||
"shortcuts": ["]:)", "]:-)", "]=)", "]=-)"]
|
||||
},
|
||||
{
|
||||
"emoji": "sob",
|
||||
"shortcuts": [":,'(", ":,'-(", ";(", ";-(", "=,'(", "=,'-("]
|
||||
},
|
||||
{
|
||||
"emoji": "stuck_out_tongue",
|
||||
"shortcuts": [":P", ":-P", "=P", "=-P"]
|
||||
},
|
||||
{
|
||||
"emoji": "sunglasses",
|
||||
"shortcuts": ["8-)", "B-)"]
|
||||
},
|
||||
{
|
||||
"emoji": "sweat",
|
||||
"shortcuts": [",:(", ",:-(", ",=(", ",=-("]
|
||||
},
|
||||
{
|
||||
"emoji": "sweat_smile",
|
||||
"shortcuts": [",:)", ",:-)", ",=)", ",=-)"]
|
||||
},
|
||||
{
|
||||
"emoji": "unamused",
|
||||
"shortcuts": [":s", ":-S", ":z", ":-Z", ":$", ":-$", "=s", "=-S", "=z", "=-Z", "=$", "=-$"]
|
||||
},
|
||||
{
|
||||
"emoji": "wink",
|
||||
"shortcuts": [";)", ";-)"]
|
||||
}
|
||||
]
|
||||
1
packages/shared/src/_shims/app/data/emojis.json
Normal file
1
packages/shared/src/_shims/app/data/emojis.json
Normal file
File diff suppressed because one or more lines are too long
11
packages/shared/src/_shims/app/hooks/useMxcUrl.ts
Normal file
11
packages/shared/src/_shims/app/hooks/useMxcUrl.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* useMxcUrl shim — the new UI's Matrix media URL resolver.
|
||||
* In Convex we already have direct URLs from storage, so this passes through.
|
||||
*/
|
||||
|
||||
export function useMxcUrl(src: string | null | undefined): string | undefined {
|
||||
if (!src) return undefined;
|
||||
return src;
|
||||
}
|
||||
|
||||
export default useMxcUrl;
|
||||
20
packages/shared/src/_shims/app/utils/dmOtherUser.ts
Normal file
20
packages/shared/src/_shims/app/utils/dmOtherUser.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* dmOtherUser shim — given a DM channel, return the other participant.
|
||||
* Real implementation reads from Convex dm query. For now, return null.
|
||||
*/
|
||||
|
||||
import type { Channel, Member } from '../../matrix-client';
|
||||
|
||||
export function dmOtherUser(_channel: Channel | null): Member | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDmOtherUser(_channel: Channel | null): Member | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDmOtherUserId(_channel: Channel | null): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export default dmOtherUser;
|
||||
41
packages/shared/src/_shims/app/utils/joinSound.ts
Normal file
41
packages/shared/src/_shims/app/utils/joinSound.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* joinSound shim — voice channel join-sound manager + preferences.
|
||||
*/
|
||||
|
||||
export function playJoinSound(_url?: string | null): void {}
|
||||
export function playLeaveSound(): void {}
|
||||
export function stopAllSounds(): void {}
|
||||
|
||||
export function getJoinSoundEnabled(): boolean {
|
||||
if (typeof localStorage === 'undefined') return true;
|
||||
const v = localStorage.getItem('joinSoundEnabled');
|
||||
return v === null ? true : v === 'true';
|
||||
}
|
||||
|
||||
export function setJoinSoundEnabled(enabled: boolean): void {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('joinSoundEnabled', enabled ? 'true' : 'false');
|
||||
}
|
||||
}
|
||||
|
||||
export function getJoinSoundVolume(): number {
|
||||
if (typeof localStorage === 'undefined') return 100;
|
||||
const v = parseInt(localStorage.getItem('joinSoundVolume') || '100', 10);
|
||||
return Number.isFinite(v) ? v : 100;
|
||||
}
|
||||
|
||||
export function setJoinSoundVolume(volume: number): void {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('joinSoundVolume', String(volume));
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
playJoinSound,
|
||||
playLeaveSound,
|
||||
stopAllSounds,
|
||||
getJoinSoundEnabled,
|
||||
setJoinSoundEnabled,
|
||||
getJoinSoundVolume,
|
||||
setJoinSoundVolume,
|
||||
};
|
||||
22
packages/shared/src/_shims/app/utils/twemojiBroken.ts
Normal file
22
packages/shared/src/_shims/app/utils/twemojiBroken.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* twemojiBroken shim — tracks which emoji codepoints Twemoji fails on.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
const broken: Set<string> = new Set();
|
||||
|
||||
export function isTwemojiBroken(codepoint: string): boolean {
|
||||
return broken.has(codepoint);
|
||||
}
|
||||
|
||||
export function markTwemojiBroken(codepoint: string): void {
|
||||
broken.add(codepoint);
|
||||
}
|
||||
|
||||
export function useBrokenTwemojiVersion(): number {
|
||||
const [version] = useState(0);
|
||||
return version;
|
||||
}
|
||||
|
||||
export default { isTwemojiBroken, markTwemojiBroken, useBrokenTwemojiVersion };
|
||||
399
packages/shared/src/_shims/matrix-client.ts
Normal file
399
packages/shared/src/_shims/matrix-client.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* Matrix client shim.
|
||||
*
|
||||
* The new UI was built against a Matrix client SDK (`@brycord/matrix-client`).
|
||||
* This project uses Convex instead. This file provides stub types and
|
||||
* manager classes so the UI compiles. Every component that reads from these
|
||||
* stubs must eventually be rewired to use Convex queries/mutations — but
|
||||
* having them here lets the big-bang UI replacement compile in one step.
|
||||
*
|
||||
* Each manager is a singleton exposing `getInstance()` + no-op methods that
|
||||
* return sensible empty values (empty arrays, null, false). UI still renders.
|
||||
*/
|
||||
|
||||
// ─── Shared primitive types ───────────────────────────────────────────────
|
||||
|
||||
export type ChannelType = 'text' | 'voice' | 'category' | 'dm' | 'group_dm';
|
||||
|
||||
export interface Channel {
|
||||
id: string;
|
||||
roomId: string;
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
topic?: string;
|
||||
parentId?: string | null;
|
||||
position?: number;
|
||||
nsfw?: boolean;
|
||||
slowmode?: number;
|
||||
icon?: string | null;
|
||||
isDM?: boolean;
|
||||
avatarUrl?: string | null;
|
||||
lastMessageTs?: number;
|
||||
memberCount?: number;
|
||||
userLimit?: number;
|
||||
bitrate?: number;
|
||||
rateLimitPerUser?: number;
|
||||
}
|
||||
|
||||
export interface Server {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
memberCount?: number;
|
||||
onlineCount?: number;
|
||||
ownerId?: string;
|
||||
description?: string;
|
||||
roomIds?: string[];
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
userId: string;
|
||||
id: string;
|
||||
displayName: string;
|
||||
name?: string;
|
||||
avatarUrl?: string | null;
|
||||
powerLevel?: number;
|
||||
roles?: string[];
|
||||
presence?: PresenceStatus;
|
||||
status?: string;
|
||||
accentColor?: string;
|
||||
bio?: string;
|
||||
joinedAt?: number;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id?: string;
|
||||
url: string;
|
||||
name: string;
|
||||
filename?: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
duration?: number;
|
||||
waveform?: number[];
|
||||
thumbnailUrl?: string | null;
|
||||
spoiler?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
eventId: string;
|
||||
channelId: string;
|
||||
senderId: string;
|
||||
authorId: string;
|
||||
authorName?: string;
|
||||
authorAvatarUrl?: string | null;
|
||||
content: string;
|
||||
body?: string;
|
||||
formattedBody?: string;
|
||||
timestamp: number;
|
||||
editedTimestamp?: number | null;
|
||||
attachments?: Attachment[];
|
||||
replyToId?: string | null;
|
||||
reactions?: MessageReaction[];
|
||||
mentions?: string[];
|
||||
mentionRoles?: string[];
|
||||
pinned?: boolean;
|
||||
type?: string;
|
||||
isRedacted?: boolean;
|
||||
isEncrypted?: boolean;
|
||||
isPending?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface MessageReaction {
|
||||
key: string;
|
||||
count: number;
|
||||
userIds: string[];
|
||||
me: boolean;
|
||||
}
|
||||
|
||||
export interface CustomEmoji {
|
||||
id: string;
|
||||
name: string;
|
||||
shortcode: string;
|
||||
url: string;
|
||||
animated?: boolean;
|
||||
packId?: string;
|
||||
}
|
||||
|
||||
export interface EmojiPack {
|
||||
id: string;
|
||||
name: string;
|
||||
emojis: CustomEmoji[];
|
||||
}
|
||||
|
||||
export type SavedMediaKind = 'image' | 'video' | 'audio' | 'gif' | 'sticker';
|
||||
|
||||
export interface SavedMediaItem {
|
||||
id: string;
|
||||
kind: SavedMediaKind;
|
||||
url: string;
|
||||
mimeType?: string;
|
||||
name?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
position: number;
|
||||
permissions: number;
|
||||
hoist?: boolean;
|
||||
mentionable?: boolean;
|
||||
powerLevel?: number;
|
||||
}
|
||||
|
||||
export interface PowerLevelAbilities {
|
||||
canKick: boolean;
|
||||
canBan: boolean;
|
||||
canInvite: boolean;
|
||||
canRedact: boolean;
|
||||
canSetState: boolean;
|
||||
canManageRoles: boolean;
|
||||
canManageChannels: boolean;
|
||||
}
|
||||
|
||||
export type PresenceStatus = 'online' | 'idle' | 'dnd' | 'offline' | 'invisible' | 'unavailable';
|
||||
|
||||
export interface PollAnswer {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface Poll {
|
||||
id: string;
|
||||
question: string;
|
||||
answers: PollAnswer[];
|
||||
multiple: boolean;
|
||||
endsAt?: number;
|
||||
votes?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export interface DeviceTrustInfo {
|
||||
deviceId: string;
|
||||
userId: string;
|
||||
trusted: boolean;
|
||||
verified: boolean;
|
||||
ed25519Key?: string;
|
||||
}
|
||||
|
||||
export type VoiceConnectionState =
|
||||
| 'disconnected'
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'reconnecting'
|
||||
| 'disconnecting'
|
||||
| 'failed'
|
||||
| 'rejoinable';
|
||||
|
||||
export type VoiceErrorReason = 'token' | 'network' | 'permission' | 'unknown';
|
||||
|
||||
export interface VoiceError {
|
||||
reason: VoiceErrorReason;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface VoiceParticipantSnapshot {
|
||||
userId: string;
|
||||
identity: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string | null;
|
||||
isMuted: boolean;
|
||||
isDeafened: boolean;
|
||||
isSpeaking: boolean;
|
||||
isScreenSharing: boolean;
|
||||
isCameraOn: boolean;
|
||||
isLocal: boolean;
|
||||
connectionQuality?: 'excellent' | 'good' | 'poor' | 'lost' | 'unknown';
|
||||
volume?: number;
|
||||
}
|
||||
|
||||
// ─── Manager singletons (stubs) ───────────────────────────────────────────
|
||||
|
||||
function singleton<T extends object>(factory: () => T): { getInstance(): T } {
|
||||
let inst: T | null = null;
|
||||
return {
|
||||
getInstance() {
|
||||
if (!inst) inst = factory();
|
||||
return inst;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const MatrixClientManager = singleton(() => ({
|
||||
getClient: () => null as any,
|
||||
getUserId: () => (typeof localStorage !== 'undefined' ? localStorage.getItem('userId') || '' : ''),
|
||||
getDisplayName: () => (typeof localStorage !== 'undefined' ? localStorage.getItem('username') || '' : ''),
|
||||
getDeviceId: () => '',
|
||||
getHomeserverUrl: () => '',
|
||||
isReady: () => true,
|
||||
start: async () => {},
|
||||
stop: async () => {},
|
||||
logout: async () => {},
|
||||
}));
|
||||
|
||||
export const RoomManager = singleton(() => ({
|
||||
getRoom: (_id: string): Channel | null => null,
|
||||
listRooms: (): Channel[] => [],
|
||||
createChannel: async (_args: unknown): Promise<string> => '',
|
||||
renameChannel: async (_id: string, _name: string) => {},
|
||||
deleteChannel: async (_id: string) => {},
|
||||
setTopic: async (_id: string, _topic: string) => {},
|
||||
getPowerLevel: (_id: string) => 0,
|
||||
getStateDefault: (_id: string) => 0,
|
||||
canManageChannel: (_id: string) => true,
|
||||
}));
|
||||
|
||||
export const MemberManager = singleton(() => ({
|
||||
getMember: (_roomId: string, _userId: string): Member | null => null,
|
||||
listMembers: (_roomId: string): Member[] => [],
|
||||
getMemberSnapshot: async (_roomId: string, _userId: string): Promise<Member | null> => null,
|
||||
setNickname: async (_roomId: string, _userId: string, _nickname: string) => {},
|
||||
kick: async (_roomId: string, _userId: string, _reason?: string) => {},
|
||||
ban: async (_roomId: string, _userId: string, _reason?: string) => {},
|
||||
}));
|
||||
|
||||
export const MessageManager = singleton(() => ({
|
||||
listMessages: (_channelId: string): Message[] => [],
|
||||
sendMessage: async (_channelId: string, _body: string): Promise<string> => '',
|
||||
editMessage: async (_channelId: string, _id: string, _body: string) => {},
|
||||
deleteMessage: async (_channelId: string, _id: string) => {},
|
||||
addReaction: async (_channelId: string, _id: string, _key: string) => {},
|
||||
removeReaction: async (_channelId: string, _id: string, _key: string) => {},
|
||||
}));
|
||||
|
||||
export const MediaManager = singleton(() => ({
|
||||
resolveMxcToObjectUrl: async (_mxc: string): Promise<string | null> => null,
|
||||
resolveMxcThumbnailToObjectUrl: async (
|
||||
_mxc: string,
|
||||
_w: number,
|
||||
_h: number,
|
||||
_mode?: string,
|
||||
): Promise<string | null> => null,
|
||||
uploadFile: async (_file: File): Promise<string> => '',
|
||||
getFileUrl: (_url: string): string => _url,
|
||||
}));
|
||||
|
||||
export const CryptoManager = singleton(() => ({
|
||||
isReady: () => true,
|
||||
getDeviceTrustInfo: (_userId: string, _deviceId: string): DeviceTrustInfo | null => null,
|
||||
verifyDevice: async (_userId: string, _deviceId: string) => {},
|
||||
exportRoomKeys: async (): Promise<string> => '',
|
||||
importRoomKeys: async (_json: string) => {},
|
||||
}));
|
||||
|
||||
export const PinsManager = singleton(() => ({
|
||||
getPinned: (_channelId: string): Message[] => [],
|
||||
pin: async (_channelId: string, _messageId: string) => {},
|
||||
unpin: async (_channelId: string, _messageId: string) => {},
|
||||
}));
|
||||
|
||||
export const SavedMediaManager = singleton(() => ({
|
||||
list: (_kind?: SavedMediaKind): SavedMediaItem[] => [],
|
||||
save: async (_item: SavedMediaItem) => {},
|
||||
remove: async (_id: string) => {},
|
||||
has: (_url: string): boolean => false,
|
||||
}));
|
||||
|
||||
export const FriendManager = singleton(() => ({
|
||||
listFriends: (): Member[] => [],
|
||||
listPending: (): Member[] => [],
|
||||
listIgnored: (): Member[] => [],
|
||||
addFriend: async (_userId: string) => {},
|
||||
acceptFriend: async (_userId: string) => {},
|
||||
ignoreFriend: async (_userId: string) => {},
|
||||
removeFriend: async (_userId: string) => {},
|
||||
unignoreFriend: async (_userId: string) => {},
|
||||
}));
|
||||
|
||||
export const PersonalNotesManager = singleton(() => ({
|
||||
getRoomId: (): string | null => null,
|
||||
ensureRoom: async (): Promise<string> => '',
|
||||
}));
|
||||
|
||||
export const SpaceManager = singleton(() => ({
|
||||
listSpaces: (): Server[] => [],
|
||||
getSpace: (_id: string): Server | null => null,
|
||||
createSpace: async (_name: string): Promise<string> => '',
|
||||
leaveSpace: async (_id: string) => {},
|
||||
}));
|
||||
|
||||
export const PresenceManager = singleton(() => ({
|
||||
getPresence: (_userId: string): PresenceStatus => 'offline',
|
||||
setPresence: async (_status: PresenceStatus) => {},
|
||||
}));
|
||||
|
||||
export const RoleManager = singleton(() => ({
|
||||
listRoles: (_serverId: string): Role[] => [],
|
||||
getRole: (_serverId: string, _roleId: string): Role | null => null,
|
||||
createRole: async (_serverId: string, _name: string): Promise<string> => '',
|
||||
updateRole: async (_serverId: string, _roleId: string, _patch: Partial<Role>) => {},
|
||||
deleteRole: async (_serverId: string, _roleId: string) => {},
|
||||
assignRole: async (_serverId: string, _userId: string, _roleId: string) => {},
|
||||
unassignRole: async (_serverId: string, _userId: string, _roleId: string) => {},
|
||||
getMemberRoles: (_serverId: string, _userId: string): string[] => [],
|
||||
getAbilitiesForMember: (_serverId: string, _userId: string): PowerLevelAbilities => ({
|
||||
canKick: false,
|
||||
canBan: false,
|
||||
canInvite: false,
|
||||
canRedact: false,
|
||||
canSetState: false,
|
||||
canManageRoles: false,
|
||||
canManageChannels: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
export const VoiceManager = singleton(() => ({
|
||||
connect: async (_channelId: string) => {},
|
||||
disconnect: async () => {},
|
||||
isConnected: (): boolean => false,
|
||||
getConnectionState: (): VoiceConnectionState => 'disconnected',
|
||||
getParticipants: (_channelId: string): VoiceParticipantSnapshot[] => [],
|
||||
setMuted: async (_muted: boolean) => {},
|
||||
setDeafened: async (_deafened: boolean) => {},
|
||||
setCameraOn: async (_on: boolean) => {},
|
||||
setScreenShare: async (_on: boolean) => {},
|
||||
}));
|
||||
|
||||
export const EmojiPackManager = singleton(() => ({
|
||||
listPacks: (): EmojiPack[] => [],
|
||||
getPack: (_id: string): EmojiPack | null => null,
|
||||
}));
|
||||
|
||||
export const VoiceModerationManager = singleton(() => ({
|
||||
serverMute: async (_userId: string) => {},
|
||||
serverUnmute: async (_userId: string) => {},
|
||||
moveMember: async (_userId: string, _channelId: string) => {},
|
||||
disconnectMember: async (_userId: string) => {},
|
||||
}));
|
||||
|
||||
// ─── Constants / helpers ──────────────────────────────────────────────────
|
||||
|
||||
export const MAX_EMOJIS_PER_PACK = 100;
|
||||
|
||||
/** Classify a MIME type into a SavedMediaKind. */
|
||||
export function classifyMediaKind(mimeType: string): SavedMediaKind {
|
||||
if (mimeType.startsWith('image/gif')) return 'gif';
|
||||
if (mimeType.startsWith('image/')) return 'image';
|
||||
if (mimeType.startsWith('video/')) return 'video';
|
||||
if (mimeType.startsWith('audio/')) return 'audio';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
/** Parse a LiveKit participant identity in the format `@user:server:device`. */
|
||||
export function parseMatrixUserFromIdentity(identity: string): { userId: string; deviceId: string } | null {
|
||||
if (!identity) return null;
|
||||
const lastColon = identity.lastIndexOf(':');
|
||||
if (lastColon === -1) return null;
|
||||
return {
|
||||
userId: identity.slice(0, lastColon),
|
||||
deviceId: identity.slice(lastColon + 1),
|
||||
};
|
||||
}
|
||||
19
packages/shared/src/_shims/mobx-react-lite.ts
Normal file
19
packages/shared/src/_shims/mobx-react-lite.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* mobx-react-lite shim — replaces MobX observer with an identity HOC.
|
||||
* Our stores are plain objects (not reactive), so observer() becomes a no-op.
|
||||
* Components still re-render on React state changes from Convex hooks.
|
||||
*/
|
||||
|
||||
import type { ComponentType, FC } from 'react';
|
||||
|
||||
export function observer<T extends ComponentType<any>>(component: T): T {
|
||||
return component;
|
||||
}
|
||||
|
||||
export function useLocalObservable<T>(factory: () => T): T {
|
||||
return factory();
|
||||
}
|
||||
|
||||
export const Observer: FC<{ children: () => JSX.Element | null }> = ({ children }) => children() as any;
|
||||
|
||||
export default { observer, useLocalObservable, Observer };
|
||||
46
packages/shared/src/_shims/mobx.ts
Normal file
46
packages/shared/src/_shims/mobx.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* mobx shim — makeAutoObservable etc. become no-ops.
|
||||
*/
|
||||
|
||||
export function makeAutoObservable<T>(obj: T): T {
|
||||
return obj;
|
||||
}
|
||||
|
||||
export function makeObservable<T>(obj: T): T {
|
||||
return obj;
|
||||
}
|
||||
|
||||
export function observable<T>(v: T): T {
|
||||
return v;
|
||||
}
|
||||
|
||||
export function action<T extends (...args: any[]) => any>(fn: T): T {
|
||||
return fn;
|
||||
}
|
||||
|
||||
export function computed<T>(fn: () => T): { get(): T } {
|
||||
return { get: fn };
|
||||
}
|
||||
|
||||
export function runInAction<T>(fn: () => T): T {
|
||||
return fn();
|
||||
}
|
||||
|
||||
export function reaction<T>(_expression: () => T, _effect: (v: T) => void): () => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
export function autorun(_fn: () => void): () => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
export default {
|
||||
makeAutoObservable,
|
||||
makeObservable,
|
||||
observable,
|
||||
action,
|
||||
computed,
|
||||
runInAction,
|
||||
reaction,
|
||||
autorun,
|
||||
};
|
||||
3
packages/shared/src/_shims/stores/AuthenticationStore.ts
Normal file
3
packages/shared/src/_shims/stores/AuthenticationStore.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { AuthenticationStore } from ".";
|
||||
export default AuthenticationStore;
|
||||
export * from ".";
|
||||
3
packages/shared/src/_shims/stores/ChannelStore.ts
Normal file
3
packages/shared/src/_shims/stores/ChannelStore.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { ChannelStore } from ".";
|
||||
export default ChannelStore;
|
||||
export * from ".";
|
||||
3
packages/shared/src/_shims/stores/CryptoStore.ts
Normal file
3
packages/shared/src/_shims/stores/CryptoStore.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { CryptoStore } from ".";
|
||||
export default CryptoStore;
|
||||
export * from ".";
|
||||
3
packages/shared/src/_shims/stores/EmojiPackStore.ts
Normal file
3
packages/shared/src/_shims/stores/EmojiPackStore.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { EmojiPackStore } from ".";
|
||||
export default EmojiPackStore;
|
||||
export * from ".";
|
||||
3
packages/shared/src/_shims/stores/FriendStore.ts
Normal file
3
packages/shared/src/_shims/stores/FriendStore.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { FriendStore } from ".";
|
||||
export default FriendStore;
|
||||
export * from ".";
|
||||
28
packages/shared/src/_shims/stores/KeybindStore.ts
Normal file
28
packages/shared/src/_shims/stores/KeybindStore.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { KeybindStore } from '.';
|
||||
|
||||
export type KeybindAction = string;
|
||||
export type KeybindCategory = string;
|
||||
|
||||
export function formatCombo(combo: string): string {
|
||||
return combo
|
||||
.split('+')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
.map((t) => (t.length === 1 ? t.toUpperCase() : t[0].toUpperCase() + t.slice(1)))
|
||||
.join('+');
|
||||
}
|
||||
|
||||
export function eventToCombo(e: KeyboardEvent): string {
|
||||
const parts: string[] = [];
|
||||
if (e.ctrlKey) parts.push('ctrl');
|
||||
if (e.shiftKey) parts.push('shift');
|
||||
if (e.altKey) parts.push('alt');
|
||||
if (e.metaKey) parts.push('meta');
|
||||
if (e.key && !['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) {
|
||||
parts.push(e.key.toLowerCase());
|
||||
}
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
export default KeybindStore;
|
||||
export { KeybindStore };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { MatrixConnectionStore } from ".";
|
||||
export default MatrixConnectionStore;
|
||||
export * from ".";
|
||||
3
packages/shared/src/_shims/stores/MessageStore.ts
Normal file
3
packages/shared/src/_shims/stores/MessageStore.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { MessageStore } from ".";
|
||||
export default MessageStore;
|
||||
export * from ".";
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PendingAttachmentStore, type PendingAttachment } from '.';
|
||||
|
||||
export type { PendingAttachment };
|
||||
export default PendingAttachmentStore;
|
||||
export { PendingAttachmentStore };
|
||||
3
packages/shared/src/_shims/stores/PersonalNotesStore.ts
Normal file
3
packages/shared/src/_shims/stores/PersonalNotesStore.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { PersonalNotesStore } from ".";
|
||||
export default PersonalNotesStore;
|
||||
export * from ".";
|
||||
3
packages/shared/src/_shims/stores/PinStore.ts
Normal file
3
packages/shared/src/_shims/stores/PinStore.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { PinStore } from ".";
|
||||
export default PinStore;
|
||||
export * from ".";
|
||||
3
packages/shared/src/_shims/stores/ReadStateStore.ts
Normal file
3
packages/shared/src/_shims/stores/ReadStateStore.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { ReadStateStore } from ".";
|
||||
export default ReadStateStore;
|
||||
export * from ".";
|
||||
3
packages/shared/src/_shims/stores/RoleStore.ts
Normal file
3
packages/shared/src/_shims/stores/RoleStore.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { RoleStore } from ".";
|
||||
export default RoleStore;
|
||||
export * from ".";
|
||||
3
packages/shared/src/_shims/stores/SavedMediaStore.ts
Normal file
3
packages/shared/src/_shims/stores/SavedMediaStore.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { SavedMediaStore } from ".";
|
||||
export default SavedMediaStore;
|
||||
export * from ".";
|
||||
11
packages/shared/src/_shims/stores/SearchStore.ts
Normal file
11
packages/shared/src/_shims/stores/SearchStore.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { SearchStore } from '.';
|
||||
import type { Message } from '../matrix-client';
|
||||
|
||||
export interface SearchResult {
|
||||
message: Message;
|
||||
channelId: string;
|
||||
matches?: string[];
|
||||
}
|
||||
|
||||
export default SearchStore;
|
||||
export { SearchStore };
|
||||
3
packages/shared/src/_shims/stores/SelectionStore.ts
Normal file
3
packages/shared/src/_shims/stores/SelectionStore.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { SelectionStore } from ".";
|
||||
export default SelectionStore;
|
||||
export * from ".";
|
||||
3
packages/shared/src/_shims/stores/ServerStore.ts
Normal file
3
packages/shared/src/_shims/stores/ServerStore.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { ServerStore } from ".";
|
||||
export default ServerStore;
|
||||
export * from ".";
|
||||
3
packages/shared/src/_shims/stores/ThemeStore.ts
Normal file
3
packages/shared/src/_shims/stores/ThemeStore.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { ThemeStore } from ".";
|
||||
export default ThemeStore;
|
||||
export * from ".";
|
||||
3
packages/shared/src/_shims/stores/TypingStore.ts
Normal file
3
packages/shared/src/_shims/stores/TypingStore.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { TypingStore } from ".";
|
||||
export default TypingStore;
|
||||
export * from ".";
|
||||
3
packages/shared/src/_shims/stores/UserStore.ts
Normal file
3
packages/shared/src/_shims/stores/UserStore.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { UserStore } from ".";
|
||||
export default UserStore;
|
||||
export * from ".";
|
||||
6
packages/shared/src/_shims/stores/VoiceSettingsStore.ts
Normal file
6
packages/shared/src/_shims/stores/VoiceSettingsStore.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { VoiceSettingsStore } from '.';
|
||||
|
||||
export type InputMode = 'voice_activity' | 'push_to_talk';
|
||||
|
||||
export default VoiceSettingsStore;
|
||||
export { VoiceSettingsStore };
|
||||
3
packages/shared/src/_shims/stores/VoiceStore.ts
Normal file
3
packages/shared/src/_shims/stores/VoiceStore.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { VoiceStore } from ".";
|
||||
export default VoiceStore;
|
||||
export * from ".";
|
||||
292
packages/shared/src/_shims/stores/index.ts
Normal file
292
packages/shared/src/_shims/stores/index.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Stores shim index.
|
||||
*
|
||||
* The new UI uses MobX stores. This project uses Convex + React Contexts.
|
||||
* Each store here is a plain object with getter methods returning sane
|
||||
* defaults. Components rewired to Convex will stop reading these stubs.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Channel,
|
||||
Server,
|
||||
Member,
|
||||
Message,
|
||||
Role,
|
||||
PresenceStatus,
|
||||
VoiceParticipantSnapshot,
|
||||
CustomEmoji,
|
||||
EmojiPack,
|
||||
SavedMediaItem,
|
||||
SavedMediaKind,
|
||||
} from '../matrix-client';
|
||||
|
||||
// ─── Helper: reactive-compatible getter wrappers ──────────────────────────
|
||||
// The original stores used MobX `makeAutoObservable`. Components call these
|
||||
// getters inside `observer()` wrappers. Our shims just return plain values,
|
||||
// and we remove `observer` via the mobx-react-lite shim.
|
||||
|
||||
const ok = <T>(v: T): T => v;
|
||||
|
||||
// ─── AuthenticationStore ─────────────────────────────────────────────────
|
||||
// isAuthenticated is a getter so the latest sessionStorage state is read
|
||||
// every access. Keeps the new AppLayout redirect logic in sync with the
|
||||
// existing session-restore flow in App.tsx (which sets sessionStorage).
|
||||
export const AuthenticationStore = {
|
||||
get isAuthenticated(): boolean {
|
||||
if (typeof sessionStorage === 'undefined') return false;
|
||||
return !!(sessionStorage.getItem('privateKey') && sessionStorage.getItem('signingKey'));
|
||||
},
|
||||
get userId(): string {
|
||||
return typeof localStorage !== 'undefined' ? localStorage.getItem('userId') || '' : '';
|
||||
},
|
||||
get username(): string {
|
||||
return typeof localStorage !== 'undefined' ? localStorage.getItem('username') || '' : '';
|
||||
},
|
||||
isLoading: false,
|
||||
login: async (_username: string, _password: string) => {},
|
||||
register: async (_username: string, _password: string) => {},
|
||||
logout: async () => {},
|
||||
getUserId: () => (typeof localStorage !== 'undefined' ? localStorage.getItem('userId') || '' : ''),
|
||||
getUsername: () => (typeof localStorage !== 'undefined' ? localStorage.getItem('username') || '' : ''),
|
||||
async restoreSession(): Promise<boolean> {
|
||||
if (typeof sessionStorage === 'undefined') return false;
|
||||
return !!(sessionStorage.getItem('privateKey') && sessionStorage.getItem('signingKey'));
|
||||
},
|
||||
};
|
||||
|
||||
// ─── ChannelStore ────────────────────────────────────────────────────────
|
||||
export const ChannelStore = {
|
||||
getChannel: (_id: string): Channel | null => null,
|
||||
getChannels: (_serverId: string): Channel[] => [],
|
||||
listChannels: (_serverId: string): Channel[] => [],
|
||||
getCategories: (_serverId: string): Channel[] => [],
|
||||
getChannelsInCategory: (_serverId: string, _categoryId: string | null): Channel[] => [],
|
||||
setChannel: (_c: Channel) => {},
|
||||
removeChannel: (_id: string) => {},
|
||||
};
|
||||
|
||||
// ─── ServerStore ─────────────────────────────────────────────────────────
|
||||
export const ServerStore = {
|
||||
getServers: (): Server[] => [],
|
||||
getServer: (_id: string): Server | null => null,
|
||||
setServer: (_s: Server) => {},
|
||||
removeServer: (_id: string) => {},
|
||||
};
|
||||
|
||||
// ─── MessageStore ────────────────────────────────────────────────────────
|
||||
export const MessageStore = {
|
||||
getMessages: (_channelId: string): Message[] => [],
|
||||
getMessage: (_channelId: string, _id: string): Message | null => null,
|
||||
addMessage: (_m: Message) => {},
|
||||
updateMessage: (_channelId: string, _id: string, _patch: Partial<Message>) => {},
|
||||
removeMessage: (_channelId: string, _id: string) => {},
|
||||
getLastReadId: (_channelId: string): string | null => null,
|
||||
};
|
||||
|
||||
// ─── UserStore ───────────────────────────────────────────────────────────
|
||||
export const UserStore = {
|
||||
getUser: (_id: string): Member | null => null,
|
||||
getMe: (): Member | null => null,
|
||||
getDisplayName: (_id: string): string => '',
|
||||
getAvatarUrl: (_id: string): string | null => null,
|
||||
getPresence: (_id: string): PresenceStatus => 'offline',
|
||||
getAccentColor: (_id: string): string | null => null,
|
||||
setStatus: async (_status: PresenceStatus) => {},
|
||||
updateProfile: async (_patch: unknown) => {},
|
||||
};
|
||||
|
||||
// ─── SelectionStore ──────────────────────────────────────────────────────
|
||||
export const SelectionStore = {
|
||||
selectedServerId: null as string | null,
|
||||
selectedChannelId: null as string | null,
|
||||
isMobileViewport: typeof window !== 'undefined' ? window.innerWidth <= 768 : false,
|
||||
selectServer: (_id: string | null) => {},
|
||||
selectChannel: (_id: string | null) => {},
|
||||
syncFromPath: (_path: string) => {},
|
||||
};
|
||||
|
||||
// ─── VoiceStore ──────────────────────────────────────────────────────────
|
||||
export const VoiceStore = {
|
||||
connectedChannelId: null as string | null,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isCameraOn: false,
|
||||
isScreenSharing: false,
|
||||
connectionState: 'disconnected' as const,
|
||||
participants: [] as VoiceParticipantSnapshot[],
|
||||
getParticipants: (_channelId: string): VoiceParticipantSnapshot[] => [],
|
||||
toggleMute: () => {},
|
||||
toggleDeafen: () => {},
|
||||
toggleCamera: () => {},
|
||||
toggleScreenShare: () => {},
|
||||
disconnect: async () => {},
|
||||
};
|
||||
|
||||
// ─── VoiceSettingsStore ──────────────────────────────────────────────────
|
||||
export const VoiceSettingsStore = {
|
||||
inputDeviceId: 'default',
|
||||
outputDeviceId: 'default',
|
||||
inputVolume: 100,
|
||||
outputVolume: 100,
|
||||
noiseSuppression: true,
|
||||
echoCancellation: true,
|
||||
autoGainControl: true,
|
||||
setInputDevice: (_id: string) => {},
|
||||
setOutputDevice: (_id: string) => {},
|
||||
};
|
||||
|
||||
// ─── RoleStore ───────────────────────────────────────────────────────────
|
||||
export const RoleStore = {
|
||||
listRoles: (_serverId: string): Role[] => [],
|
||||
getRole: (_serverId: string, _id: string): Role | null => null,
|
||||
getMemberRoles: (_serverId: string, _userId: string): Role[] => [],
|
||||
getHighestHoistedRole: (_serverId: string, _userId: string): Role | null => null,
|
||||
};
|
||||
|
||||
// ─── ReadStateStore ──────────────────────────────────────────────────────
|
||||
export const ReadStateStore = {
|
||||
isUnread: (_channelId: string): boolean => false,
|
||||
getUnreadCount: (_channelId: string): number => 0,
|
||||
getMentionCount: (_channelId: string): number => 0,
|
||||
getLastReadId: (_channelId: string): string | null => null,
|
||||
markRead: async (_channelId: string) => {},
|
||||
};
|
||||
|
||||
// ─── TypingStore ─────────────────────────────────────────────────────────
|
||||
export const TypingStore = {
|
||||
getTypingUsers: (_channelId: string): Member[] => [],
|
||||
startTyping: async (_channelId: string) => {},
|
||||
};
|
||||
|
||||
// ─── FriendStore ─────────────────────────────────────────────────────────
|
||||
export const FriendStore = {
|
||||
getFriendChannels: (): Channel[] => [],
|
||||
getPendingChannels: (): Channel[] => [],
|
||||
getFriends: (): Member[] => [],
|
||||
getPending: (): Member[] => [],
|
||||
getIgnored: (): Member[] => [],
|
||||
isFriend: (_id: string): boolean => false,
|
||||
isPending: (_id: string): boolean => false,
|
||||
isIgnored: (_id: string): boolean => false,
|
||||
};
|
||||
|
||||
// ─── PinStore ────────────────────────────────────────────────────────────
|
||||
export const PinStore = {
|
||||
getPinned: (_channelId: string): Message[] => [],
|
||||
isPinned: (_channelId: string, _messageId: string): boolean => false,
|
||||
};
|
||||
|
||||
// ─── SavedMediaStore ─────────────────────────────────────────────────────
|
||||
export const SavedMediaStore = {
|
||||
list: (_kind?: SavedMediaKind): SavedMediaItem[] => [],
|
||||
isSaved: (_url: string): boolean => false,
|
||||
add: async (_item: SavedMediaItem) => {},
|
||||
remove: async (_id: string) => {},
|
||||
};
|
||||
|
||||
// ─── EmojiPackStore ──────────────────────────────────────────────────────
|
||||
export const EmojiPackStore = {
|
||||
listPacks: (): EmojiPack[] => [],
|
||||
getPack: (_id: string): EmojiPack | null => null,
|
||||
getEmoji: (_id: string): CustomEmoji | null => null,
|
||||
findByShortcode: (_shortcode: string): CustomEmoji | null => null,
|
||||
};
|
||||
|
||||
// ─── PendingAttachmentStore ──────────────────────────────────────────────
|
||||
export interface PendingAttachment {
|
||||
id: string;
|
||||
channelId: string;
|
||||
file: File;
|
||||
name: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
previewUrl?: string;
|
||||
progress?: number;
|
||||
error?: string | null;
|
||||
spoiler?: boolean;
|
||||
description?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export const PendingAttachmentStore = {
|
||||
getAttachments: (_channelId: string): PendingAttachment[] => [],
|
||||
addAttachment: (_a: PendingAttachment) => {},
|
||||
removeAttachment: (_channelId: string, _id: string) => {},
|
||||
updateAttachment: (_channelId: string, _id: string, _patch: Partial<PendingAttachment>) => {},
|
||||
clearAttachments: (_channelId: string) => {},
|
||||
};
|
||||
|
||||
// ─── PersonalNotesStore ──────────────────────────────────────────────────
|
||||
export const PersonalNotesStore = {
|
||||
getRoomId: (): string | null => null,
|
||||
isPersonalNotesRoom: (_id: string): boolean => false,
|
||||
};
|
||||
|
||||
// ─── KeybindStore ────────────────────────────────────────────────────────
|
||||
export const KeybindStore = {
|
||||
getCombo: (_action: string): string => '',
|
||||
getBinding: (_action: string): string => '',
|
||||
setCombo: (_action: string, _combo: string) => {},
|
||||
listActions: (): string[] => [],
|
||||
};
|
||||
|
||||
// ─── SearchStore ─────────────────────────────────────────────────────────
|
||||
export const SearchStore = {
|
||||
isOpen: false,
|
||||
query: '',
|
||||
results: [] as Message[],
|
||||
setQuery: (_q: string) => {},
|
||||
search: async (_q: string) => {},
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
// ─── CryptoStore ─────────────────────────────────────────────────────────
|
||||
export const CryptoStore = {
|
||||
isReady: true,
|
||||
isSetup: true,
|
||||
needsSetup: false,
|
||||
};
|
||||
|
||||
// ─── MatrixConnectionStore ───────────────────────────────────────────────
|
||||
export const MatrixConnectionStore = {
|
||||
isReady: true,
|
||||
isConnecting: false,
|
||||
isConnected: true,
|
||||
error: null as string | null,
|
||||
};
|
||||
|
||||
// ─── ThemeStore ──────────────────────────────────────────────────────────
|
||||
export const ThemeStore = {
|
||||
theme: 'dark' as 'light' | 'dark',
|
||||
setTheme: (_t: 'light' | 'dark') => {},
|
||||
};
|
||||
|
||||
// Default exports so `import Foo from '@app/stores/Foo'` also works
|
||||
export default {
|
||||
AuthenticationStore,
|
||||
ChannelStore,
|
||||
ServerStore,
|
||||
MessageStore,
|
||||
UserStore,
|
||||
SelectionStore,
|
||||
VoiceStore,
|
||||
VoiceSettingsStore,
|
||||
RoleStore,
|
||||
ReadStateStore,
|
||||
TypingStore,
|
||||
FriendStore,
|
||||
PinStore,
|
||||
SavedMediaStore,
|
||||
EmojiPackStore,
|
||||
PendingAttachmentStore,
|
||||
PersonalNotesStore,
|
||||
KeybindStore,
|
||||
SearchStore,
|
||||
CryptoStore,
|
||||
MatrixConnectionStore,
|
||||
ThemeStore,
|
||||
};
|
||||
|
||||
// Workaround for `ok` not being used (keeps TS from erroring on strict mode)
|
||||
export { ok };
|
||||
@@ -1,62 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||
|
||||
function getUserColor(name) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < (name || '').length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
|
||||
}
|
||||
|
||||
const Avatar = ({ username, avatarUrl, size = 40, className = '', style = {}, onClick }) => {
|
||||
const sizeStr = `${size}px`;
|
||||
const fontSize = `${Math.max(size * 0.45, 10)}px`;
|
||||
|
||||
if (avatarUrl) {
|
||||
return (
|
||||
<img
|
||||
className={className}
|
||||
src={avatarUrl}
|
||||
alt={username || '?'}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
width: sizeStr,
|
||||
height: sizeStr,
|
||||
borderRadius: '50%',
|
||||
objectFit: 'cover',
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
width: sizeStr,
|
||||
height: sizeStr,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: getUserColor(username || 'U'),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
fontSize,
|
||||
userSelect: 'none',
|
||||
flexShrink: 0,
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{(username || '?').substring(0, 1).toUpperCase()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Avatar;
|
||||
@@ -1,134 +0,0 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import Cropper from 'react-easy-crop';
|
||||
|
||||
function getCroppedImg(imageSrc, pixelCrop) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 256;
|
||||
canvas.height = 256;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(
|
||||
image,
|
||||
pixelCrop.x, pixelCrop.y, pixelCrop.width, pixelCrop.height,
|
||||
0, 0, 256, 256
|
||||
);
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return reject(new Error('Canvas toBlob failed'));
|
||||
resolve(blob);
|
||||
}, 'image/png');
|
||||
};
|
||||
image.onerror = reject;
|
||||
image.src = imageSrc;
|
||||
});
|
||||
}
|
||||
|
||||
const AvatarCropModal = ({ imageUrl, onApply, onCancel, cropShape = 'round' }) => {
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState(null);
|
||||
|
||||
const onCropComplete = useCallback((_croppedArea, croppedPixels) => {
|
||||
setCroppedAreaPixels(croppedPixels);
|
||||
}, []);
|
||||
|
||||
const handleApply = useCallback(async () => {
|
||||
if (!croppedAreaPixels) return;
|
||||
const blob = await getCroppedImg(imageUrl, croppedAreaPixels);
|
||||
onApply(blob);
|
||||
}, [imageUrl, croppedAreaPixels, onApply]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKey, true);
|
||||
return () => window.removeEventListener('keydown', handleKey, true);
|
||||
}, [onCancel]);
|
||||
|
||||
return (
|
||||
<div className="avatar-crop-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}>
|
||||
<div className="avatar-crop-dialog">
|
||||
{/* Header */}
|
||||
<div className="avatar-crop-header">
|
||||
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: 'var(--header-primary)' }}>
|
||||
Edit Image
|
||||
</h2>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
background: 'none', border: 'none', color: 'var(--header-secondary)',
|
||||
fontSize: '24px', cursor: 'pointer', padding: '4px', lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Crop area */}
|
||||
<div className="avatar-crop-area">
|
||||
<Cropper
|
||||
image={imageUrl}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
cropShape={cropShape}
|
||||
showGrid={false}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zoom slider */}
|
||||
<div className="avatar-crop-slider-row">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="var(--header-secondary)">
|
||||
<path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/>
|
||||
</svg>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.01}
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="avatar-crop-slider"
|
||||
/>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--header-secondary)">
|
||||
<path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="avatar-crop-actions">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
background: 'none', border: 'none', color: 'var(--header-primary)',
|
||||
cursor: 'pointer', fontSize: '14px', fontWeight: 500, padding: '8px 16px',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
style={{
|
||||
backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
|
||||
borderRadius: '4px', padding: '8px 24px', cursor: 'pointer',
|
||||
fontSize: '14px', fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvatarCropModal;
|
||||
@@ -1,222 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useMutation } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
|
||||
const ChangeNicknameModal = ({ targetUserId, targetUsername, currentNickname, actorUserId, onClose }) => {
|
||||
const [nickname, setNickname] = useState(currentNickname || '');
|
||||
const inputRef = useRef(null);
|
||||
const setNicknameMutation = useMutation(api.auth.setNickname);
|
||||
const isSelf = targetUserId === actorUserId;
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await setNicknameMutation({
|
||||
actorUserId,
|
||||
targetUserId,
|
||||
displayName: nickname,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to set nickname:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
try {
|
||||
await setNicknameMutation({
|
||||
actorUserId,
|
||||
targetUserId,
|
||||
displayName: '',
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to reset nickname:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.85)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10001,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: '440px',
|
||||
maxWidth: '90vw',
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.4)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '16px 16px 0 16px', position: 'relative' }}>
|
||||
<h2 style={{ color: 'var(--header-primary)', margin: '0 0 16px 0', fontSize: '20px', fontWeight: 600 }}>
|
||||
Change Nickname
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '12px',
|
||||
right: '12px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--interactive-normal)',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ padding: '0 16px 16px 16px' }}>
|
||||
{/* Notice */}
|
||||
{!isSelf && (
|
||||
<div style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
borderLeft: '4px solid var(--text-warning, #faa61a)',
|
||||
borderRadius: '4px',
|
||||
padding: '12px',
|
||||
marginBottom: '16px',
|
||||
fontSize: '14px',
|
||||
color: 'var(--text-normal)',
|
||||
lineHeight: '1.4',
|
||||
}}>
|
||||
Nicknames are visible to everyone on this server. Do not change them unless you are enforcing a naming system or clearing a bad nickname.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
<label style={{
|
||||
color: 'var(--header-secondary)',
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: '8px',
|
||||
display: 'block',
|
||||
}}>
|
||||
Nickname
|
||||
</label>
|
||||
|
||||
{/* Input */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value.slice(0, 32))}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={targetUsername}
|
||||
maxLength={32}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--text-normal)',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Reset link */}
|
||||
<div
|
||||
onClick={handleReset}
|
||||
style={{
|
||||
color: 'var(--text-link, #00a8fc)',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
marginTop: '8px',
|
||||
marginBottom: '4px',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
Reset Nickname
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
padding: '16px',
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
borderTop: '1px solid var(--border-subtle)',
|
||||
}}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px 0',
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
color: 'var(--text-normal)',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px 0',
|
||||
backgroundColor: 'var(--brand-experiment)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangeNicknameModal;
|
||||
@@ -1,213 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useConvex } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
|
||||
const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
||||
const [name, setName] = useState(channel.name);
|
||||
const [activeTab, setActiveTab] = useState('Overview');
|
||||
|
||||
const convex = useConvex();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await convex.mutation(api.channels.rename, { id: channel._id, name });
|
||||
onRename(channel._id, name);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to update channel: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Are you sure you want to delete this channel? This cannot be undone.')) return;
|
||||
|
||||
try {
|
||||
await convex.mutation(api.channels.remove, { id: channel._id });
|
||||
onDelete(channel._id);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to delete channel: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
color: 'var(--text-normal)'
|
||||
}}>
|
||||
{/* Sidebar */}
|
||||
<div style={{
|
||||
width: '218px',
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-end',
|
||||
padding: '60px 6px 60px 20px'
|
||||
}}>
|
||||
<div style={{ width: '100%', padding: '0 10px' }}>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '700',
|
||||
color: 'var(--text-muted)',
|
||||
marginBottom: '6px',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
{channel.name} Text Channels
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => setActiveTab('Overview')}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: activeTab === 'Overview' ? 'var(--background-modifier-selected)' : 'transparent',
|
||||
color: activeTab === 'Overview' ? 'var(--header-primary)' : 'var(--header-secondary)',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '2px',
|
||||
fontSize: '15px'
|
||||
}}
|
||||
>
|
||||
Overview
|
||||
</div>
|
||||
|
||||
<div style={{ height: '1px', backgroundColor: 'var(--border-subtle)', margin: '8px 0' }} />
|
||||
|
||||
<div
|
||||
onClick={() => setActiveTab('Delete')}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
color: '#ed4245',
|
||||
cursor: 'pointer',
|
||||
fontSize: '15px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between'
|
||||
}}
|
||||
>
|
||||
Delete Channel
|
||||
<span>🗑️</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, display: 'flex', justifyContent: 'flex-start', overflowY: 'auto' }}>
|
||||
<div style={{ flex: 1, maxWidth: '740px', padding: '60px 40px 80px', position: 'relative' }}>
|
||||
<h2 style={{ color: 'var(--header-primary)', margin: 0, marginBottom: '20px' }}>
|
||||
{activeTab === 'Delete' ? 'Delete Channel' : 'Overview'}
|
||||
</h2>
|
||||
|
||||
{activeTab === 'Overview' && (
|
||||
<>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
color: 'var(--header-secondary)',
|
||||
fontSize: '12px',
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
Channel Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '10px',
|
||||
color: 'var(--text-normal)',
|
||||
fontSize: '16px',
|
||||
outline: 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '20px' }}>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
backgroundColor: '#3ba55c',
|
||||
color: 'var(--header-primary)',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '10px 24px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'Delete' && (
|
||||
<div style={{ backgroundColor: 'var(--bg-tertiary)', padding: '16px', borderRadius: '8px', border: '1px solid #ed4245' }}>
|
||||
<h3 style={{ color: 'var(--header-primary)', marginTop: 0 }}>Are you sure?</h3>
|
||||
<p style={{ color: 'var(--header-secondary)' }}>
|
||||
Deleting <b>#{channel.name}</b> cannot be undone. All messages and keys will be lost forever.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
style={{
|
||||
backgroundColor: '#ed4245',
|
||||
color: 'var(--header-primary)',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '10px 24px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Delete Channel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<div style={{ flex: '0 0 36px', paddingTop: '60px', marginLeft: '8px' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
width: '36px', height: '36px', borderRadius: '50%',
|
||||
border: '2px solid var(--header-secondary)', background: 'transparent',
|
||||
color: 'var(--header-secondary)', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '18px',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--header-secondary)', textAlign: 'center', marginTop: '4px' }}>
|
||||
ESC
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: '0.5' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelSettingsModal;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,175 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import { useOnlineUsers } from '../contexts/PresenceContext';
|
||||
import Tooltip from './Tooltip';
|
||||
|
||||
const ChatHeader = ({
|
||||
channelName,
|
||||
channelType,
|
||||
channelTopic,
|
||||
channelId,
|
||||
onToggleMembers,
|
||||
showMembers,
|
||||
onTogglePinned,
|
||||
serverName,
|
||||
isMobile,
|
||||
onMobileBack,
|
||||
onStartCall,
|
||||
isDMCallActive,
|
||||
onOpenMembersScreen,
|
||||
// Search props
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
onSearchSubmit,
|
||||
onSearchFocus,
|
||||
onSearchBlur,
|
||||
searchInputRef,
|
||||
searchActive,
|
||||
}) => {
|
||||
const isDM = channelType === 'dm';
|
||||
const searchPlaceholder = isDM ? 'Search' : `Search ${serverName || 'Server'}`;
|
||||
|
||||
// Query members on mobile text channels only for online count
|
||||
const shouldQueryMembers = isMobile && !isDM && channelId;
|
||||
const members = useQuery(
|
||||
api.members.getChannelMembers,
|
||||
shouldQueryMembers ? { channelId } : "skip"
|
||||
) || [];
|
||||
const { resolveStatus } = useOnlineUsers();
|
||||
const onlineCount = useMemo(() => {
|
||||
if (!shouldQueryMembers) return 0;
|
||||
return members.filter(m => resolveStatus(m.status, m.id) !== 'offline').length;
|
||||
}, [members, resolveStatus, shouldQueryMembers]);
|
||||
|
||||
const handleSearchKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onSearchSubmit?.();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onSearchBlur?.();
|
||||
e.target.blur();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-header">
|
||||
<div className="chat-header-left">
|
||||
{isMobile && onMobileBack && (
|
||||
<button className="mobile-back-btn" onClick={onMobileBack}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||
</button>
|
||||
)}
|
||||
{isMobile && !isDM ? (
|
||||
<button className="mobile-channel-header-tap" onClick={onOpenMembersScreen}>
|
||||
<div className="mobile-channel-header-top">
|
||||
<span className="chat-header-icon">#</span>
|
||||
<span className="chat-header-name">{channelName}</span>
|
||||
<svg className="mobile-channel-chevron" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9.29 6.71a1 1 0 0 0 0 1.41L13.17 12l-3.88 3.88a1 1 0 1 0 1.42 1.41l4.59-4.59a1 1 0 0 0 0-1.41L10.71 6.7a1 1 0 0 0-1.42 0Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="mobile-channel-header-bottom">
|
||||
<span className="mobile-online-dot" />
|
||||
<span className="mobile-online-count">{onlineCount ?? 0} Online</span>
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<span className="chat-header-icon">{isDM ? '@' : '#'}</span>
|
||||
<span className="chat-header-name">{channelName}</span>
|
||||
{channelTopic && !isDM && !isMobile && (
|
||||
<>
|
||||
<div className="chat-header-divider" />
|
||||
<span className="chat-header-topic" title={channelTopic}>{channelTopic}</span>
|
||||
</>
|
||||
)}
|
||||
{isDM && <span className="chat-header-status-text"></span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="chat-header-right">
|
||||
{!isDM && !isMobile && (
|
||||
<Tooltip text="Threads" position="bottom">
|
||||
<button className="chat-header-btn">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M5.43309 21C5.35842 21 5.30189 20.9325 5.31494 20.859L5.99991 17H2.14274C2.06819 17 2.01168 16.9327 2.02453 16.8593L2.33253 15.0993C2.34258 15.0419 2.39244 15 2.45074 15H6.34991L7.14991 10.5H3.29274C3.21819 10.5 3.16168 10.4327 3.17453 10.3593L3.48253 8.59926C3.49258 8.54185 3.54244 8.5 3.60074 8.5H7.49991L8.25674 4.49395C8.26688 4.43665 8.31672 4.395 8.37491 4.395H10.1919C10.2666 4.395 10.3231 4.4625 10.3101 4.536L9.59991 8.5H14.0999L14.8568 4.49395C14.8669 4.43665 14.9167 4.395 14.9749 4.395H16.7919C16.8666 4.395 16.9231 4.4625 16.9101 4.536L16.1999 8.5H20.0571C20.1316 8.5 20.1881 8.56734 20.1753 8.64074L19.8673 10.4007C19.8572 10.4581 19.8074 10.5 19.7491 10.5H15.8499L15.0499 15H18.9071C18.9816 15 19.0381 15.0673 19.0253 15.1407L18.7173 16.9007C18.7072 16.9581 18.6574 17 18.5991 17H14.6999L13.9431 21.006C13.9329 21.0634 13.8831 21.105 13.8249 21.105H12.0079C11.9332 21.105 11.8767 21.0375 11.8897 20.964L12.5999 17H8.09991L7.34309 21.006C7.33295 21.0634 7.28311 21.105 7.22491 21.105H5.43309V21ZM8.44991 15H12.9499L13.7499 10.5H9.24991L8.44991 15Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isDM && !isMobile && (
|
||||
<Tooltip text={isDMCallActive ? "Join Call" : "Start Call"} position="bottom">
|
||||
<button
|
||||
className={`chat-header-btn ${isDMCallActive ? 'active' : ''}`}
|
||||
onClick={onStartCall}
|
||||
style={isDMCallActive ? { color: '#3ba55c' } : undefined}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6.62 10.79a15.053 15.053 0 006.59 6.59l2.2-2.2a1.003 1.003 0 011.01-.24c1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1C10.07 21 3 13.93 3 4c0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.1.31.03.66-.25 1.02l-2.2 2.2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip text="Pinned Messages" position="bottom">
|
||||
<button className="chat-header-btn" onClick={onTogglePinned}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path fill="currentColor" d="M19.38 11.38a3 3 0 0 0 4.24 0l.03-.03a.5.5 0 0 0 0-.7L13.35.35a.5.5 0 0 0-.7 0l-.03.03a3 3 0 0 0 0 4.24L13 5l-2.92 2.92-3.65-.34a2 2 0 0 0-1.6.58l-.62.63a1 1 0 0 0 0 1.42l9.58 9.58a1 1 0 0 0 1.42 0l.63-.63a2 2 0 0 0 .58-1.6l-.34-3.64L19 11zM9.07 17.07a.5.5 0 0 1-.08.77l-5.15 3.43a.5.5 0 0 1-.63-.06l-.42-.42a.5.5 0 0 1-.06-.63L6.16 15a.5.5 0 0 1 .77-.08l2.14 2.14Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{!isDM && !isMobile && (
|
||||
<Tooltip text={showMembers ? "Hide Members" : "Show Members"} position="bottom">
|
||||
<button
|
||||
className={`chat-header-btn ${showMembers ? 'active' : ''}`}
|
||||
onClick={onToggleMembers}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path fill="currentColor" d="M14.5 8a3 3 0 1 0-2.7-4.3c-.2.4.06.86.44 1.12a5 5 0 0 1 2.14 3.08c.01.06.06.1.12.1ZM18.44 17.27c.15.43.54.73 1 .73h1.06c.83 0 1.5-.67 1.5-1.5a7.5 7.5 0 0 0-6.5-7.43c-.55-.08-.99.38-1.1.92-.06.3-.15.6-.26.87-.23.58-.05 1.3.47 1.63a9.53 9.53 0 0 1 3.83 4.78ZM12.5 9a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM2 20.5a7.5 7.5 0 0 1 15 0c0 .83-.67 1.5-1.5 1.5a.2.2 0 0 1-.2-.16c-.2-.96-.56-1.87-.88-2.54-.1-.23-.42-.15-.42.1v2.1a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-2.1c0-.25-.31-.33-.42-.1-.32.67-.67 1.58-.88 2.54a.2.2 0 0 1-.2.16A1.5 1.5 0 0 1 2 20.5Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<Tooltip text="Notification Settings" position="bottom">
|
||||
<button className="chat-header-btn">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18 9V14C18 15.657 19.344 17 21 17V18H3V17C4.656 17 6 15.657 6 14V9C6 5.686 8.686 3 12 3C15.314 3 18 5.686 18 9ZM11.9999 22C10.5239 22 9.24993 20.955 8.99993 19.5H14.9999C14.7499 20.955 13.4759 22 11.9999 22Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<div className="chat-header-search-wrapper" ref={searchInputRef}>
|
||||
<svg className="chat-header-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21.71 20.29L18 16.61A9 9 0 1016.61 18l3.68 3.68a1 1 0 001.42 0 1 1 0 000-1.39zM11 18a7 7 0 110-14 7 7 0 010 14z"/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
className={`chat-header-search ${searchActive ? 'focused' : ''}`}
|
||||
value={searchQuery || ''}
|
||||
onChange={(e) => onSearchQueryChange?.(e.target.value)}
|
||||
onFocus={onSearchFocus}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="chat-header-search-clear"
|
||||
onClick={() => onSearchQueryChange?.('')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatHeader;
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const ColoredIcon = React.memo(({ src, color, size = '20px', style = {} }) => {
|
||||
if (!color) {
|
||||
return (
|
||||
<div style={{ width: size, height: size, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, ...style }}>
|
||||
<img src={src} alt="" style={{ width: size, height: size, objectFit: 'contain' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
}}>
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
style={{
|
||||
width: size, height: size,
|
||||
objectFit: 'contain',
|
||||
filter: `drop-shadow(${size} 0 0 ${color})`,
|
||||
transform: `translateX(-${size})`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ColoredIcon;
|
||||
@@ -1,245 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useConvex } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import Tooltip from './Tooltip';
|
||||
import Avatar from './Avatar';
|
||||
import ColoredIcon from './ColoredIcon';
|
||||
import { useOnlineUsers } from '../contexts/PresenceContext';
|
||||
import friendsIcon from '../assets/icons/friends.svg';
|
||||
|
||||
const STATUS_COLORS = {
|
||||
online: '#3ba55c',
|
||||
idle: '#faa61a',
|
||||
dnd: '#ed4245',
|
||||
invisible: '#747f8d',
|
||||
offline: '#747f8d',
|
||||
};
|
||||
|
||||
const STATUS_LABELS = {
|
||||
online: 'Online',
|
||||
idle: 'Idle',
|
||||
dnd: 'Do Not Disturb',
|
||||
invisible: 'Offline',
|
||||
offline: 'Offline',
|
||||
};
|
||||
|
||||
const getUserColor = (username) => {
|
||||
if (!username) return '#5865F2';
|
||||
const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < username.length; i++) {
|
||||
hash = username.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
};
|
||||
|
||||
const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM, voiceStates }) => {
|
||||
const [showUserPicker, setShowUserPicker] = useState(false);
|
||||
const [allUsers, setAllUsers] = useState([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchFocused, setSearchFocused] = useState(false);
|
||||
const searchRef = useRef(null);
|
||||
const searchInputRef = useRef(null);
|
||||
const { resolveStatus } = useOnlineUsers();
|
||||
|
||||
const convex = useConvex();
|
||||
|
||||
const handleOpenUserPicker = async () => {
|
||||
setShowUserPicker(true);
|
||||
setSearchQuery('');
|
||||
try {
|
||||
const data = await convex.query(api.auth.getPublicKeys, {});
|
||||
const myId = localStorage.getItem('userId');
|
||||
setAllUsers(data.filter(u => u.id !== myId));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchFocus = async () => {
|
||||
setSearchFocused(true);
|
||||
if (allUsers.length === 0) {
|
||||
try {
|
||||
const data = await convex.query(api.auth.getPublicKeys, {});
|
||||
const myId = localStorage.getItem('userId');
|
||||
setAllUsers(data.filter(u => u.id !== myId));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (showUserPicker && searchRef.current) {
|
||||
searchRef.current.focus();
|
||||
}
|
||||
}, [showUserPicker]);
|
||||
|
||||
const filteredUsers = allUsers.filter(u =>
|
||||
u.username?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const handleCloseDM = (e, dm) => {
|
||||
e.stopPropagation();
|
||||
// If closing the active DM, switch back to friends
|
||||
if (activeDMChannel?.channel_id === dm.channel_id) {
|
||||
onSelectDM('friends');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="dm-search-wrapper">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
className="dm-search-input"
|
||||
placeholder="Find or start a conversation"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onFocus={handleSearchFocus}
|
||||
onBlur={() => {
|
||||
setTimeout(() => setSearchFocused(false), 200);
|
||||
}}
|
||||
/>
|
||||
{searchFocused && searchQuery && filteredUsers.length > 0 && (
|
||||
<div className="dm-search-dropdown">
|
||||
{filteredUsers.slice(0, 8).map(user => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="dm-search-result"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setSearchQuery('');
|
||||
setSearchFocused(false);
|
||||
onOpenDM(user.id, user.username);
|
||||
}}
|
||||
>
|
||||
<Avatar username={user.username} size={24} style={{ marginRight: '8px' }} />
|
||||
<span>{user.username}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Picker Modal */}
|
||||
{showUserPicker && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)', zIndex: 100,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||
}}
|
||||
onClick={() => setShowUserPicker(false)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)', borderRadius: '8px', padding: '16px',
|
||||
width: '400px', maxHeight: '500px', display: 'flex', flexDirection: 'column'
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<h3 style={{ color: 'var(--header-primary)', margin: '0 0 4px 0', fontSize: '16px' }}>Select a User</h3>
|
||||
<p style={{ color: 'var(--header-secondary)', fontSize: '12px', margin: '0 0 12px 0' }}>Start a new direct message conversation.</p>
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
placeholder="Type a username..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
style={{
|
||||
width: '100%', backgroundColor: 'var(--bg-tertiary)', border: '1px solid var(--border-subtle)',
|
||||
borderRadius: '4px', color: 'var(--text-normal)', padding: '8px 12px', fontSize: '14px',
|
||||
outline: 'none', marginBottom: '8px', boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, overflowY: 'auto', maxHeight: '300px' }}>
|
||||
{filteredUsers.map(user => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="dm-picker-user"
|
||||
onClick={() => { setShowUserPicker(false); onOpenDM(user.id, user.username); }}
|
||||
>
|
||||
<Avatar username={user.username} size={32} style={{ marginRight: '12px' }} />
|
||||
<span style={{ fontWeight: '500' }}>{user.username}</span>
|
||||
</div>
|
||||
))}
|
||||
{filteredUsers.length === 0 && (
|
||||
<div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '16px', fontSize: '13px' }}>
|
||||
No users found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Friends Button */}
|
||||
<div
|
||||
className={`dm-friends-btn ${!activeDMChannel ? 'active' : ''}`}
|
||||
onClick={() => onSelectDM('friends')}
|
||||
>
|
||||
<div style={{ marginRight: '12px' }}>
|
||||
<ColoredIcon src={friendsIcon} color="var(--interactive-normal)" size="24px" />
|
||||
</div>
|
||||
<span style={{ fontWeight: 500 }}>Friends</span>
|
||||
</div>
|
||||
|
||||
{/* DM List Header */}
|
||||
<div style={{ fontFamily: 'gg sans', display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px 16px 8px', color: '#96989d', fontSize: '11px', fontWeight: 'bold', borderTop: 'solid 1px var(--app-frame-border)'}}>
|
||||
<span>Direct Messages</span>
|
||||
</div>
|
||||
|
||||
{/* DM Channel List */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0 8px 8px' }}>
|
||||
{(dmChannels || []).map(dm => {
|
||||
const isActive = activeDMChannel?.channel_id === dm.channel_id;
|
||||
const effectiveStatus = resolveStatus(dm.other_user_status, dm.other_user_id);
|
||||
return (
|
||||
<div
|
||||
key={dm.channel_id}
|
||||
className={`dm-item ${isActive ? 'dm-item-active' : ''}`}
|
||||
onClick={() => onSelectDM({ channel_id: dm.channel_id, other_username: dm.other_username })}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', flex: 1, minWidth: 0 }}>
|
||||
<div style={{ position: 'relative', marginRight: '12px', flexShrink: 0 }}>
|
||||
<Avatar username={dm.other_username} avatarUrl={dm.other_user_avatar_url} size={32} />
|
||||
<div style={{
|
||||
position: 'absolute', bottom: -2, right: -2,
|
||||
width: 10, height: 10, borderRadius: '50%',
|
||||
backgroundColor: STATUS_COLORS[effectiveStatus] || STATUS_COLORS.offline,
|
||||
border: '2px solid var(--bg-secondary)'
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ overflow: 'hidden', flex: 1 }}>
|
||||
<div style={{ color: isActive ? 'var(--header-primary)' : 'var(--text-normal)', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', fontWeight: '500' }}>
|
||||
{dm.other_username}
|
||||
</div>
|
||||
{voiceStates && voiceStates[dm.channel_id]?.length > 0 && (
|
||||
<div style={{ color: '#3ba55c', fontSize: '11px', fontWeight: '500' }}>
|
||||
In Call
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dm-close-btn" onClick={(e) => handleCloseDM(e, dm)}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(!dmChannels || dmChannels.length === 0) && (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: '13px', textAlign: 'center', padding: '16px 8px' }}>
|
||||
No DMs yet. Click + to start a conversation.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DMList;
|
||||
@@ -1,271 +0,0 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useVoice } from '../contexts/VoiceContext';
|
||||
import { VideoRenderer, useParticipantTrack } from '../utils/streamUtils.jsx';
|
||||
import { getUserPref, setUserPref } from '../utils/userPreferences';
|
||||
import { usePlatform } from '../platform';
|
||||
|
||||
const MIN_WIDTH = 240;
|
||||
const MIN_HEIGHT = 135;
|
||||
const MAX_WIDTH_RATIO = 0.75;
|
||||
const MAX_HEIGHT_RATIO = 0.75;
|
||||
const DEFAULT_WIDTH = 320;
|
||||
const DEFAULT_HEIGHT = 180;
|
||||
const ASPECT_RATIO = 16 / 9;
|
||||
|
||||
const LIVE_BADGE_STYLE = {
|
||||
backgroundColor: '#ed4245', borderRadius: '4px', padding: '2px 6px',
|
||||
color: 'white', fontSize: '10px', fontWeight: 'bold',
|
||||
textTransform: 'uppercase', letterSpacing: '0.5px',
|
||||
};
|
||||
|
||||
const FloatingStreamPiP = ({ onGoBackToStream }) => {
|
||||
const { room, watchingStreamOf, setWatchingStreamOf, voiceStates, activeChannelId } = useVoice();
|
||||
const { settings } = usePlatform();
|
||||
const pipUserId = localStorage.getItem('userId');
|
||||
|
||||
const [position, setPosition] = useState(() => getUserPref(pipUserId, 'pipPosition', { x: -1, y: -1 }));
|
||||
const [size, setSize] = useState(() => getUserPref(pipUserId, 'pipSize', { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }));
|
||||
const [hovering, setHovering] = useState(false);
|
||||
|
||||
const isDragging = useRef(false);
|
||||
const isResizing = useRef(false);
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
const resizeStart = useRef({ x: 0, y: 0, width: 0, height: 0 });
|
||||
const containerRef = useRef(null);
|
||||
|
||||
// Initialize position to bottom-right on mount (only if no saved position)
|
||||
useEffect(() => {
|
||||
if (position.x === -1 && position.y === -1) {
|
||||
setPosition({
|
||||
x: window.innerWidth - size.width - 24,
|
||||
y: window.innerHeight - size.height - 24,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Find the watched participant from the room
|
||||
const participant = (() => {
|
||||
if (!room || !watchingStreamOf) return null;
|
||||
if (room.localParticipant.identity === watchingStreamOf) return room.localParticipant;
|
||||
return room.remoteParticipants.get(watchingStreamOf) || null;
|
||||
})();
|
||||
|
||||
const screenTrack = useParticipantTrack(participant, 'screenshare');
|
||||
|
||||
// Resolve streamer username from voiceStates
|
||||
const streamerUsername = (() => {
|
||||
if (!watchingStreamOf) return '';
|
||||
for (const users of Object.values(voiceStates)) {
|
||||
const u = users.find(u => u.userId === watchingStreamOf);
|
||||
if (u) return u.username;
|
||||
}
|
||||
return watchingStreamOf;
|
||||
})();
|
||||
|
||||
// Drag handlers
|
||||
const handleDragStart = useCallback((e) => {
|
||||
if (isResizing.current) return;
|
||||
e.preventDefault();
|
||||
isDragging.current = true;
|
||||
dragOffset.current = {
|
||||
x: e.clientX - position.x,
|
||||
y: e.clientY - position.y,
|
||||
};
|
||||
}, [position]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e) => {
|
||||
if (isDragging.current) {
|
||||
let newX = e.clientX - dragOffset.current.x;
|
||||
let newY = e.clientY - dragOffset.current.y;
|
||||
|
||||
// Constrain to window bounds
|
||||
newX = Math.max(0, Math.min(newX, window.innerWidth - size.width));
|
||||
newY = Math.max(0, Math.min(newY, window.innerHeight - size.height));
|
||||
|
||||
setPosition({ x: newX, y: newY });
|
||||
}
|
||||
|
||||
if (isResizing.current) {
|
||||
const dx = e.clientX - resizeStart.current.x;
|
||||
const dy = e.clientY - resizeStart.current.y;
|
||||
|
||||
// Use the larger delta to maintain aspect ratio
|
||||
const maxW = window.innerWidth * MAX_WIDTH_RATIO;
|
||||
const maxH = window.innerHeight * MAX_HEIGHT_RATIO;
|
||||
|
||||
let newWidth = resizeStart.current.width + dx;
|
||||
newWidth = Math.max(MIN_WIDTH, Math.min(maxW, newWidth));
|
||||
let newHeight = newWidth / ASPECT_RATIO;
|
||||
|
||||
if (newHeight > maxH) {
|
||||
newHeight = maxH;
|
||||
newWidth = newHeight * ASPECT_RATIO;
|
||||
}
|
||||
if (newHeight < MIN_HEIGHT) {
|
||||
newHeight = MIN_HEIGHT;
|
||||
newWidth = newHeight * ASPECT_RATIO;
|
||||
}
|
||||
|
||||
setSize({ width: Math.round(newWidth), height: Math.round(newHeight) });
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDragging.current = false;
|
||||
isResizing.current = false;
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [size]);
|
||||
|
||||
// Resize handler
|
||||
const handleResizeStart = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isResizing.current = true;
|
||||
resizeStart.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
};
|
||||
}, [size]);
|
||||
|
||||
// Debounced persist of PiP position and size
|
||||
useEffect(() => {
|
||||
if (position.x === -1 && position.y === -1) return;
|
||||
const timer = setTimeout(() => {
|
||||
setUserPref(pipUserId, 'pipPosition', position, settings);
|
||||
setUserPref(pipUserId, 'pipSize', size, settings);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [position, size, pipUserId]);
|
||||
|
||||
const handleStopWatching = useCallback(() => {
|
||||
setWatchingStreamOf(null);
|
||||
}, [setWatchingStreamOf]);
|
||||
|
||||
if (!watchingStreamOf || !participant) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseEnter={() => setHovering(true)}
|
||||
onMouseLeave={() => setHovering(false)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
zIndex: 1000,
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
||||
backgroundColor: 'black',
|
||||
cursor: isDragging.current ? 'grabbing' : 'default',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onMouseDown={handleDragStart}
|
||||
>
|
||||
{/* Video content */}
|
||||
{screenTrack ? (
|
||||
<VideoRenderer track={screenTrack} style={{ objectFit: 'contain', pointerEvents: 'none' }} />
|
||||
) : (
|
||||
<div style={{
|
||||
width: '100%', height: '100%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#72767d', fontSize: '13px',
|
||||
}}>
|
||||
Loading stream...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover overlay */}
|
||||
{hovering && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px',
|
||||
transition: 'opacity 0.15s',
|
||||
}}>
|
||||
{/* Top row: streamer name + back button */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span style={{ color: 'white', fontSize: '12px', fontWeight: '600' }}>
|
||||
{streamerUsername}
|
||||
</span>
|
||||
<span style={LIVE_BADGE_STYLE}>LIVE</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onGoBackToStream(); }}
|
||||
title="Back to Stream"
|
||||
style={{
|
||||
width: '28px', height: '28px', borderRadius: '4px',
|
||||
backgroundColor: 'rgba(255,255,255,0.15)', border: 'none',
|
||||
color: 'white', fontSize: '16px', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'background-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.25)'}
|
||||
onMouseLeave={e => e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.15)'}
|
||||
>
|
||||
{/* Back arrow icon */}
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M6.5 12.5L2 8L6.5 3.5M2.5 8H14" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bottom row: stop watching */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleStopWatching(); }}
|
||||
style={{
|
||||
backgroundColor: 'rgba(0,0,0,0.6)', color: 'white', border: 'none',
|
||||
padding: '6px 14px', borderRadius: '4px', fontWeight: '600',
|
||||
fontSize: '12px', cursor: 'pointer',
|
||||
transition: 'background-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.8)'}
|
||||
onMouseLeave={e => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.6)'}
|
||||
>
|
||||
Stop Watching
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resize handle (bottom-right corner) */}
|
||||
<div
|
||||
onMouseDown={handleResizeStart}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
cursor: 'nwse-resize',
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
{/* Diagonal grip lines */}
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" style={{ opacity: hovering ? 0.6 : 0 , transition: 'opacity 0.15s' }}>
|
||||
<line x1="14" y1="4" x2="4" y2="14" stroke="white" strokeWidth="1.5"/>
|
||||
<line x1="14" y1="8" x2="8" y2="14" stroke="white" strokeWidth="1.5"/>
|
||||
<line x1="14" y1="12" x2="12" y2="14" stroke="white" strokeWidth="1.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingStreamPiP;
|
||||
@@ -1,149 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import Avatar from './Avatar';
|
||||
import ColoredIcon from './ColoredIcon';
|
||||
import { useOnlineUsers } from '../contexts/PresenceContext';
|
||||
import friendsIcon from '../assets/icons/friends.svg';
|
||||
|
||||
const FriendsView = ({ onOpenDM }) => {
|
||||
const [activeTab, setActiveTab] = useState('Online');
|
||||
const [addFriendSearch, setAddFriendSearch] = useState('');
|
||||
|
||||
const myId = localStorage.getItem('userId');
|
||||
const { resolveStatus } = useOnlineUsers();
|
||||
|
||||
const allUsers = useQuery(api.auth.getPublicKeys) || [];
|
||||
const users = allUsers.filter(u => u.id !== myId);
|
||||
|
||||
const getUserColor = (username) => {
|
||||
if (!username) return '#747f8d';
|
||||
const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < username.length; i++) {
|
||||
hash = username.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
};
|
||||
|
||||
const STATUS_COLORS = {
|
||||
online: '#3ba55c',
|
||||
idle: '#faa61a',
|
||||
dnd: '#ed4245',
|
||||
invisible: '#747f8d',
|
||||
offline: '#747f8d',
|
||||
};
|
||||
|
||||
const filteredUsers = activeTab === 'Online'
|
||||
? users.filter(u => resolveStatus(u.status, u.id) !== 'offline')
|
||||
: activeTab === 'Add Friend'
|
||||
? users.filter(u => u.username?.toLowerCase().includes(addFriendSearch.toLowerCase()))
|
||||
: users;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, backgroundColor: 'var(--bg-primary)', height: '100vh' }}>
|
||||
{/* Top Bar */}
|
||||
<div style={{
|
||||
height: '48px',
|
||||
borderBottom: '1px solid var(--border-subtle)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 16px',
|
||||
color: '#fff',
|
||||
fontWeight: 'bold',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginRight: '16px', paddingRight: '16px', borderRight: '1px solid var(--border-subtle)' }}>
|
||||
<div style={{ marginRight: '12px' }}>
|
||||
<ColoredIcon src={friendsIcon} color="var(--interactive-normal)" size="24px" />
|
||||
</div>
|
||||
Friends
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||
{['Online', 'All'].map(tab => (
|
||||
<div
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className="friends-tab"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
color: activeTab === tab ? 'var(--header-primary)' : 'var(--header-secondary)',
|
||||
backgroundColor: activeTab === tab ? 'rgba(255,255,255,0.06)' : 'transparent',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
{tab}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List Header */}
|
||||
<div style={{ padding: '16px 20px 8px' }}>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
color: 'var(--header-secondary)',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
{activeTab === 'Add Friend' ? 'USERS' : activeTab} — {filteredUsers.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Friends List */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0 20px' }}>
|
||||
{filteredUsers.map(user => {
|
||||
const effectiveStatus = resolveStatus(user.status, user.id);
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
className="friend-item"
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ position: 'relative', marginRight: '12px' }}>
|
||||
<Avatar username={user.username} avatarUrl={user.avatarUrl} size={32} />
|
||||
<div style={{
|
||||
position: 'absolute', bottom: -2, right: -2,
|
||||
width: 10, height: 10, borderRadius: '50%',
|
||||
backgroundColor: STATUS_COLORS[effectiveStatus] || STATUS_COLORS.offline,
|
||||
border: '2px solid var(--bg-primary)'
|
||||
}} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: 'var(--header-primary)', fontWeight: '600' }}>
|
||||
{user.username ?? 'Unknown'}
|
||||
</div>
|
||||
<div style={{ color: 'var(--header-secondary)', fontSize: '12px' }}>
|
||||
{effectiveStatus === 'dnd' ? 'Do Not Disturb' : effectiveStatus.charAt(0).toUpperCase() + effectiveStatus.slice(1)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<div
|
||||
className="friend-action-btn"
|
||||
onClick={() => onOpenDM && onOpenDM(user.id, user.username)}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M4.79805 3C3.80445 3 2.99805 3.8055 2.99805 4.8V15.6C2.99805 16.5936 3.80445 17.4 4.79805 17.4H8.39805L11.998 21L15.598 17.4H19.198C20.1925 17.4 20.998 16.5936 20.998 15.6V4.8C20.998 3.8055 20.1925 3 19.198 3H4.79805Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="friend-action-btn">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 16C13.1046 16 14 15.1046 14 14C14 12.8954 13.1046 12 12 12C10.8954 12 10 12.8954 10 14C10 15.1046 10.8954 16 12 16Z" />
|
||||
<path d="M12 10C13.1046 10 14 9.10457 14 8C14 6.89543 13.1046 6 12 6C10.8954 6 10 6.89543 10 8C10 9.10457 10.8954 10 12 10Z" />
|
||||
<path d="M12 22C13.1046 22 14 21.1046 14 20C14 18.8954 13.1046 18 12 18C10.8954 18 10 18.8954 10 20C10 21.1046 10.8954 22 12 22Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FriendsView;
|
||||
@@ -1,342 +0,0 @@
|
||||
import CategorizedEmojis, { AllEmojis } from '../assets/emojis';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useConvex, useQuery } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
|
||||
const EmojiItem = ({ emoji, onSelect }) => (
|
||||
<div
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onSelect && onSelect({ type: 'emoji', ...emoji })}
|
||||
title={`:${emoji.name}:`}
|
||||
style={{ cursor: 'pointer', padding: '4px', borderRadius: '4px' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--background-modifier-hover)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<img src={emoji.src} alt={emoji.name} style={{ width: '32px', height: '32px' }} loading="lazy" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const emojiGridStyle = { display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: '4px' };
|
||||
|
||||
const GifContent = ({ search, results, categories, onSelect, onCategoryClick }) => {
|
||||
if (search || results.length > 0) {
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
||||
{results.map(gif => (
|
||||
<img
|
||||
key={gif.id}
|
||||
src={gif.media_formats?.tinygif?.url || gif.media_formats?.gif?.url}
|
||||
alt={gif.title}
|
||||
style={{ width: '100%', borderRadius: '4px', cursor: 'pointer' }}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onSelect(gif.media_formats?.gif?.url || gif.src)}
|
||||
/>
|
||||
))}
|
||||
{results.length === 0 && <div style={{ color: 'var(--header-secondary)', gridColumn: 'span 2', textAlign: 'center' }}>No GIFs found</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
backgroundImage: 'linear-gradient(to right, #4a90e2, #9013fe)',
|
||||
borderRadius: '4px',
|
||||
padding: '20px',
|
||||
marginBottom: '12px',
|
||||
color: '#fff',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '16px',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer'
|
||||
}}>
|
||||
Favorites
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
||||
{categories.map(cat => (
|
||||
<div
|
||||
key={cat.name}
|
||||
onClick={() => onCategoryClick(cat.name)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: '100px',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'var(--bg-tertiary)'
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={cat.src}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', opacity: 0.6 }}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0, left: 0, right: 0, bottom: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||
color: '#fff',
|
||||
fontWeight: 'bold',
|
||||
textTransform: 'capitalize'
|
||||
}}>
|
||||
{cat.name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EmojiContent = ({ search, onSelect, collapsedCategories, toggleCategory, customEmojis = [] }) => {
|
||||
if (search) {
|
||||
const q = search.toLowerCase().replace(/:/g, '');
|
||||
const customFiltered = customEmojis.filter(e => e.name.toLowerCase().includes(q));
|
||||
const builtinFiltered = AllEmojis.filter(e => e.name.toLowerCase().includes(q));
|
||||
const filtered = [...customFiltered, ...builtinFiltered].slice(0, 100);
|
||||
return (
|
||||
<div className="emoji-grid" style={{ height: '100%' }}>
|
||||
<div style={emojiGridStyle}>
|
||||
{filtered.map((emoji, idx) => (
|
||||
<EmojiItem key={idx} emoji={emoji} onSelect={onSelect} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CategoryHeader = ({ name, collapsed }) => (
|
||||
<div
|
||||
onClick={() => toggleCategory(name)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '8px',
|
||||
padding: '4px',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--background-modifier-hover)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="var(--header-secondary)"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
marginRight: '8px',
|
||||
transform: collapsed ? 'rotate(-90deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s'
|
||||
}}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
<h3 style={{
|
||||
color: 'var(--header-secondary)',
|
||||
fontSize: '12px',
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: 700,
|
||||
margin: 0
|
||||
}}>
|
||||
{name}
|
||||
</h3>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="emoji-grid" style={{ height: '100%' }}>
|
||||
{customEmojis.length > 0 && (
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<CategoryHeader name="Custom" collapsed={collapsedCategories['Custom']} />
|
||||
{!collapsedCategories['Custom'] && (
|
||||
<div style={emojiGridStyle}>
|
||||
{customEmojis.map((emoji) => (
|
||||
<EmojiItem key={emoji._id || emoji.name} emoji={emoji} onSelect={onSelect} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(CategorizedEmojis).map(([category, emojis]) => (
|
||||
<div key={category} style={{ marginBottom: '8px' }}>
|
||||
<CategoryHeader name={category} collapsed={collapsedCategories[category]} />
|
||||
{!collapsedCategories[category] && (
|
||||
<div style={emojiGridStyle}>
|
||||
{emojis.map((emoji, idx) => (
|
||||
<EmojiItem key={idx} emoji={emoji} onSelect={onSelect} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const initialCollapsed = Object.fromEntries(
|
||||
Object.keys(CategorizedEmojis).map(cat => [cat, true])
|
||||
);
|
||||
|
||||
const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) => {
|
||||
const [search, setSearch] = useState('');
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [results, setResults] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [internalActiveTab, setInternalActiveTab] = useState(initialTab || 'GIFs');
|
||||
const [collapsedCategories, setCollapsedCategories] = useState(initialCollapsed);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const convex = useConvex();
|
||||
const customEmojis = useQuery(api.customEmojis.list) || [];
|
||||
|
||||
const activeTab = currentTab !== undefined ? currentTab : internalActiveTab;
|
||||
const setActiveTab = (tab) => {
|
||||
if (onTabChange) onTabChange(tab);
|
||||
if (currentTab === undefined) setInternalActiveTab(tab);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
convex.action(api.gifs.categories, {})
|
||||
.then(data => {
|
||||
if (data.categories) setCategories(data.categories);
|
||||
})
|
||||
.catch(err => console.error('Failed to load categories', err));
|
||||
|
||||
if (inputRef.current) inputRef.current.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchResults = async () => {
|
||||
if (!search || activeTab !== 'GIFs') {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await convex.action(api.gifs.search, { q: search });
|
||||
setResults(data.results || []);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const timeout = setTimeout(fetchResults, 500);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [search, activeTab]);
|
||||
|
||||
const toggleCategory = (categoryName) => {
|
||||
setCollapsedCategories(prev => ({
|
||||
...prev,
|
||||
[categoryName]: !prev[categoryName]
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="gif-picker"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '50px',
|
||||
right: '0',
|
||||
width: '400px',
|
||||
height: '450px',
|
||||
backgroundColor: 'var(--embed-background)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 8px 16px rgba(0,0,0,0.24)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
zIndex: 1000
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header / Tabs */}
|
||||
<div style={{ padding: '16px 16px 8px 16px', display: 'flex', gap: '16px', borderBottom: '1px solid var(--bg-tertiary)' }}>
|
||||
{['GIFs', 'Stickers', 'Emoji'].map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: activeTab === tab ? 'var(--header-primary)' : 'var(--header-secondary)',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
paddingBottom: '4px',
|
||||
borderBottom: activeTab === tab ? '2px solid var(--brand-experiment)' : '2px solid transparent'
|
||||
}}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div style={{ padding: '8px 16px' }}>
|
||||
<div style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 8px'
|
||||
}}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={activeTab === 'Emoji' ? "Find the perfect emoji" : "Search Tenor"}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--text-normal)',
|
||||
padding: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none'
|
||||
}}
|
||||
/>
|
||||
<div style={{ padding: '4px' }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--header-secondary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0 16px 16px 16px' }}>
|
||||
{loading ? (
|
||||
<div style={{ color: 'var(--header-secondary)', textAlign: 'center', padding: '20px' }}>Loading...</div>
|
||||
) : activeTab === 'GIFs' ? (
|
||||
<GifContent search={search} results={results} categories={categories} onSelect={onSelect} onCategoryClick={setSearch} />
|
||||
) : (
|
||||
<EmojiContent search={search} onSelect={onSelect} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} customEmojis={customEmojis} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GifPicker;
|
||||
@@ -1,28 +0,0 @@
|
||||
import React from 'react';
|
||||
import Avatar from './Avatar';
|
||||
|
||||
const IncomingCallUI = ({ callerUsername, callerAvatarUrl, onJoin, onReject }) => {
|
||||
return (
|
||||
<div className="incoming-call-ui">
|
||||
<div className="incoming-call-avatar-ring">
|
||||
<Avatar username={callerUsername} avatarUrl={callerAvatarUrl} size={80} />
|
||||
</div>
|
||||
<div className="incoming-call-username">{callerUsername}</div>
|
||||
<div className="incoming-call-subtitle">Incoming call...</div>
|
||||
<div className="incoming-call-buttons">
|
||||
<button className="incoming-call-btn join" onClick={onJoin} title="Join Call">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6.62 10.79a15.05 15.05 0 0 0 6.59 6.59l2.2-2.2a1 1 0 0 1 1.01-.24c1.12.37 2.33.57 3.58.57a1 1 0 0 1 1 1V20a1 1 0 0 1-1 1A17 17 0 0 1 3 4a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1c0 1.25.2 2.46.57 3.58a1 1 0 0 1-.25 1.01l-2.2 2.2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button className="incoming-call-btn reject" onClick={onReject} title="Reject">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28a1 1 0 0 1-.71-.3L.29 13.08a1 1 0 0 1 0-1.41C3.57 8.55 7.53 7 12 7s8.43 1.55 11.71 4.67a1 1 0 0 1 0 1.41l-2.48 2.48a1 1 0 0 1-.7.29c-.27 0-.52-.11-.7-.28a11.27 11.27 0 0 0-2.67-1.85.99.99 0 0 1-.56-.9v-3.1A15.9 15.9 0 0 0 12 9z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IncomingCallUI;
|
||||
@@ -1,256 +0,0 @@
|
||||
import React, { useState, useRef, useEffect, useLayoutEffect } from 'react';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import { useOnlineUsers } from '../contexts/PresenceContext';
|
||||
import { useVoice } from '../contexts/VoiceContext';
|
||||
import { CrownIcon, SharingIcon } from '../assets/icons';
|
||||
import ColoredIcon from './ColoredIcon';
|
||||
import ChangeNicknameModal from './ChangeNicknameModal';
|
||||
import avatarDecoStatic from '../assets/avatar_decorations/a_dcfe10bac4a782ffb5eefef7a8003115.png';
|
||||
import avatarDecoAnimated from '../assets/avatar_decorations/passthrough/a_dcfe10bac4a782ffb5eefef7a8003115.png';
|
||||
|
||||
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||
|
||||
function getUserColor(name) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
|
||||
}
|
||||
|
||||
const STATUS_COLORS = {
|
||||
online: '#3ba55c',
|
||||
idle: '#faa61a',
|
||||
dnd: '#ed4245',
|
||||
invisible: '#747f8d',
|
||||
offline: '#747f8d',
|
||||
};
|
||||
|
||||
const MemberContextMenu = ({ x, y, onClose, member, isSelf, canManageNicknames, onChangeNickname, onMessage, onStartCall }) => {
|
||||
const menuRef = useRef(null);
|
||||
const [pos, setPos] = useState({ top: y, left: x });
|
||||
|
||||
useEffect(() => {
|
||||
const h = () => onClose();
|
||||
window.addEventListener('click', h);
|
||||
window.addEventListener('close-context-menus', h);
|
||||
return () => { window.removeEventListener('click', h); window.removeEventListener('close-context-menus', h); };
|
||||
}, [onClose]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!menuRef.current) return;
|
||||
const rect = menuRef.current.getBoundingClientRect();
|
||||
let newTop = y, newLeft = x;
|
||||
if (x + rect.width > window.innerWidth) newLeft = x - rect.width;
|
||||
if (y + rect.height > window.innerHeight) newTop = y - rect.height;
|
||||
if (newLeft < 0) newLeft = 10;
|
||||
if (newTop < 0) newTop = 10;
|
||||
setPos({ top: newTop, left: newLeft });
|
||||
}, [x, y]);
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="context-menu" style={{ top: pos.top, left: pos.left }} onClick={(e) => e.stopPropagation()}>
|
||||
{(isSelf || canManageNicknames) && (
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={(e) => { e.stopPropagation(); onChangeNickname(); onClose(); }}
|
||||
>
|
||||
<span>Change Nickname</span>
|
||||
</div>
|
||||
)}
|
||||
{(isSelf || canManageNicknames) && (!isSelf) && (
|
||||
<div className="context-menu-separator" />
|
||||
)}
|
||||
{!isSelf && (
|
||||
<>
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={(e) => { e.stopPropagation(); onMessage(); onClose(); }}
|
||||
>
|
||||
<span>Message</span>
|
||||
</div>
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={(e) => { e.stopPropagation(); onStartCall(); onClose(); }}
|
||||
>
|
||||
<span>Start a Call</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MembersList = ({ channelId, visible, onMemberClick, userId, myPermissions, onOpenDM, onStartCallWithUser }) => {
|
||||
const members = useQuery(
|
||||
api.members.getChannelMembers,
|
||||
channelId ? { channelId } : "skip"
|
||||
) || [];
|
||||
const { resolveStatus } = useOnlineUsers();
|
||||
const { voiceStates } = useVoice();
|
||||
const [contextMenu, setContextMenu] = useState(null);
|
||||
const [nicknameModal, setNicknameModal] = useState(null);
|
||||
|
||||
const usersInVoice = new Set();
|
||||
const usersScreenSharing = new Set();
|
||||
Object.values(voiceStates).forEach(users => {
|
||||
users.forEach(u => {
|
||||
usersInVoice.add(u.userId);
|
||||
if (u.isScreenSharing) usersScreenSharing.add(u.userId);
|
||||
});
|
||||
});
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const onlineMembers = members.filter(m => resolveStatus(m.status, m.id) !== 'offline');
|
||||
const offlineMembers = members.filter(m => resolveStatus(m.status, m.id) === 'offline');
|
||||
|
||||
// Group online members by highest hoisted role
|
||||
const roleGroups = {};
|
||||
const ungrouped = [];
|
||||
|
||||
onlineMembers.forEach(member => {
|
||||
const hoistedRole = member.roles.find(r => r.isHoist && r.name !== '@everyone' && r.name !== 'Owner');
|
||||
if (hoistedRole) {
|
||||
const key = `${hoistedRole.position}_${hoistedRole.name}`;
|
||||
if (!roleGroups[key]) {
|
||||
roleGroups[key] = { role: hoistedRole, members: [] };
|
||||
}
|
||||
roleGroups[key].members.push(member);
|
||||
} else {
|
||||
ungrouped.push(member);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort groups by position descending
|
||||
const sortedGroups = Object.values(roleGroups).sort((a, b) => b.role.position - a.role.position);
|
||||
|
||||
const handleContextMenu = (e, member) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.dispatchEvent(new Event('close-context-menus'));
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, member });
|
||||
};
|
||||
|
||||
const renderMember = (member) => {
|
||||
const displayRole = member.roles.find(r => r.name !== '@everyone' && r.name !== 'Owner') || null;
|
||||
const nameColor = displayRole ? displayRole.color : '#fff';
|
||||
const isOwner = member.roles.some(r => r.name === 'Owner');
|
||||
const effectiveStatus = resolveStatus(member.status, member.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className="member-item"
|
||||
onClick={() => onMemberClick && onMemberClick(member)}
|
||||
onContextMenu={(e) => handleContextMenu(e, member)}
|
||||
style={effectiveStatus === 'offline' ? { opacity: 0.3 } : {}}
|
||||
>
|
||||
<div className="member-avatar-wrapper">
|
||||
{member.avatarUrl ? (
|
||||
<img
|
||||
className="member-avatar"
|
||||
src={member.avatarUrl}
|
||||
alt={member.username}
|
||||
style={{ objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="member-avatar"
|
||||
style={{ backgroundColor: getUserColor(member.username) }}
|
||||
>
|
||||
{(member.displayName || member.username).substring(0, 1).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
{/* <img className="avatar-decoration avatar-decoration-static" src={avatarDecoStatic} alt="" aria-hidden="true" /> */}
|
||||
{/* <img className="avatar-decoration avatar-decoration-animated" src={avatarDecoAnimated} alt="" aria-hidden="true" /> */}
|
||||
<div
|
||||
className="member-status-dot"
|
||||
style={{ backgroundColor: STATUS_COLORS[effectiveStatus] || STATUS_COLORS.offline }}
|
||||
/>
|
||||
</div>
|
||||
<div className="member-info">
|
||||
<span className="member-name" style={{ color: nameColor, display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
{member.displayName || member.username}
|
||||
{isOwner && <ColoredIcon src={CrownIcon} color="var(--text-feedback-warning)" size="14px" />}
|
||||
</span>
|
||||
{usersScreenSharing.has(member.id) ? (
|
||||
<div className="member-screen-sharing-indicator">
|
||||
<img src={SharingIcon} alt="" />
|
||||
Sharing their screen
|
||||
</div>
|
||||
) : usersInVoice.has(member.id) ? (
|
||||
<div className="member-voice-indicator">
|
||||
<svg viewBox="0 0 24 24" fill="#3ba55c">
|
||||
<path d="M12 3a1 1 0 0 0-1-1h-.06a1 1 0 0 0-.74.32L5.92 7H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.92l4.28 4.68a1 1 0 0 0 .74.32H11a1 1 0 0 0 1-1zm3.1 17.75c-.58.14-1.1-.33-1.1-.92v-.03c0-.5.37-.92.85-1.05a7 7 0 0 0 0-13.5A1.11 1.11 0 0 1 14 4.2v-.03c0-.6.52-1.06 1.1-.92a9 9 0 0 1 0 17.5"/>
|
||||
<path d="M15.16 16.51c-.57.28-1.16-.2-1.16-.83v-.14c0-.43.28-.8.63-1.02a3 3 0 0 0 0-5.04c-.35-.23-.63-.6-.63-1.02v-.14c0-.63.59-1.1 1.16-.83a5 5 0 0 1 0 9.02"/>
|
||||
</svg>
|
||||
In Voice
|
||||
</div>
|
||||
) : member.customStatus ? (
|
||||
<div style={{ fontSize: '12px', color: 'var(--text-muted)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{member.customStatus}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="members-list">
|
||||
{/* <img src={avatarDecoAnimated} alt="" style={{ display: 'none' }} aria-hidden="true" /> */}
|
||||
{sortedGroups.map(group => (
|
||||
<React.Fragment key={group.role.name}>
|
||||
<div className="members-role-header">
|
||||
{group.role.name} — {group.members.length}
|
||||
</div>
|
||||
{group.members.map(renderMember)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{ungrouped.length > 0 && (
|
||||
<>
|
||||
<div className="members-role-header">
|
||||
ONLINE — {ungrouped.length}
|
||||
</div>
|
||||
{ungrouped.map(renderMember)}
|
||||
</>
|
||||
)}
|
||||
{offlineMembers.length > 0 && (
|
||||
<>
|
||||
<div className="members-role-header">
|
||||
OFFLINE — {offlineMembers.length}
|
||||
</div>
|
||||
{offlineMembers.map(renderMember)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{contextMenu && (
|
||||
<MemberContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
member={contextMenu.member}
|
||||
isSelf={contextMenu.member.id === userId}
|
||||
canManageNicknames={!!myPermissions?.manage_nicknames}
|
||||
onClose={() => setContextMenu(null)}
|
||||
onChangeNickname={() => setNicknameModal(contextMenu.member)}
|
||||
onMessage={() => onOpenDM && onOpenDM(contextMenu.member.id, contextMenu.member.displayName || contextMenu.member.username)}
|
||||
onStartCall={() => onStartCallWithUser && onStartCallWithUser(contextMenu.member.id, contextMenu.member.displayName || contextMenu.member.username)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{nicknameModal && (
|
||||
<ChangeNicknameModal
|
||||
targetUserId={nicknameModal.id}
|
||||
targetUsername={nicknameModal.username}
|
||||
currentNickname={nicknameModal.displayName || ''}
|
||||
actorUserId={userId}
|
||||
onClose={() => setNicknameModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MembersList;
|
||||
@@ -1,84 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import Avatar from './Avatar';
|
||||
|
||||
const MentionMenu = ({ items, selectedIndex, onSelect, onHover }) => {
|
||||
const scrollerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollerRef.current) return;
|
||||
const selected = scrollerRef.current.querySelector('.mention-menu-row.selected');
|
||||
if (selected) selected.scrollIntoView({ block: 'nearest' });
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
const roleItems = items.filter(i => i.type === 'role');
|
||||
const memberItems = items.filter(i => i.type === 'member');
|
||||
|
||||
let globalIndex = 0;
|
||||
|
||||
return (
|
||||
<div className="mention-menu">
|
||||
<div className="mention-menu-scroller" ref={scrollerRef}>
|
||||
{roleItems.length > 0 && (
|
||||
<>
|
||||
<div className="mention-menu-section-header">Roles</div>
|
||||
{roleItems.map((role) => {
|
||||
const idx = globalIndex++;
|
||||
const displayName = role.name.startsWith('@') ? role.name : `@${role.name}`;
|
||||
return (
|
||||
<div
|
||||
key={`role-${role._id}`}
|
||||
className={`mention-menu-row${idx === selectedIndex ? ' selected' : ''}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onSelect(role)}
|
||||
onMouseEnter={() => onHover(idx)}
|
||||
>
|
||||
<div
|
||||
className="mention-menu-role-icon"
|
||||
style={{ backgroundColor: role.color || '#99aab5' }}
|
||||
>
|
||||
@
|
||||
</div>
|
||||
<span
|
||||
className="mention-menu-row-primary"
|
||||
style={role.color ? { color: role.color } : undefined}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{memberItems.length > 0 && (
|
||||
<>
|
||||
<div className="mention-menu-section-header">Members</div>
|
||||
{memberItems.map((member) => {
|
||||
const idx = globalIndex++;
|
||||
const topRole = member.roles && member.roles.length > 0 ? member.roles[0] : null;
|
||||
const nameColor = topRole?.color || undefined;
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className={`mention-menu-row${idx === selectedIndex ? ' selected' : ''}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onSelect(member)}
|
||||
onMouseEnter={() => onHover(idx)}
|
||||
>
|
||||
<Avatar username={member.username} avatarUrl={member.avatarUrl} size={24} />
|
||||
<span className="mention-menu-row-primary" style={nameColor ? { color: nameColor } : undefined}>
|
||||
{member.displayName || member.username}
|
||||
</span>
|
||||
<span className="mention-menu-row-secondary">{member.username}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MentionMenu;
|
||||
@@ -1,408 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import {
|
||||
EmojieIcon,
|
||||
EditIcon,
|
||||
ReplyIcon,
|
||||
MoreIcon,
|
||||
DeleteIcon,
|
||||
PinIcon,
|
||||
} from '../assets/icons';
|
||||
import { getEmojiUrl, AllEmojis } from '../assets/emojis';
|
||||
import Tooltip from './Tooltip';
|
||||
import Avatar from './Avatar';
|
||||
import ColoredIcon from './ColoredIcon';
|
||||
import { usePlatform } from '../platform';
|
||||
|
||||
const fireIcon = getEmojiUrl('nature', 'fire');
|
||||
const heartIcon = getEmojiUrl('symbols', 'heart');
|
||||
const thumbsupIcon = getEmojiUrl('people', 'thumbsup');
|
||||
|
||||
const ICON_COLOR_DEFAULT = 'hsl(240, 4.294%, 68.039%)';
|
||||
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||
|
||||
export const getUserColor = (name) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) { hash = name.charCodeAt(i) + ((hash << 5) - hash); }
|
||||
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
|
||||
};
|
||||
|
||||
export const extractUrls = (text) => {
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
||||
return text.match(urlRegex) || [];
|
||||
};
|
||||
|
||||
export const formatMentions = (text, roles) => {
|
||||
if (!text) return '';
|
||||
// First pass: replace @role:Name with role mention links
|
||||
let result = text.replace(/@role:([^\s]+)/g, (match, name) => {
|
||||
const role = roles?.find(r => r.name === name);
|
||||
const color = role?.color || '#99aab5';
|
||||
const displayName = name.startsWith('@') ? name : `@${name}`;
|
||||
return `[${displayName}](rolemention://${encodeURIComponent(name)}?color=${encodeURIComponent(color)})`;
|
||||
});
|
||||
// Second pass: replace @-prefixed role names (like @everyone) directly
|
||||
if (roles) {
|
||||
for (const role of roles) {
|
||||
if (role.name.startsWith('@')) {
|
||||
const escaped = role.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp(`(?<!\\[)${escaped}\\b`, 'g');
|
||||
const color = role.color || '#99aab5';
|
||||
result = result.replace(re, `[${role.name}](rolemention://${encodeURIComponent(role.name)}?color=${encodeURIComponent(color)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Third pass: replace @username with user mention links (skip already-linked @)
|
||||
result = result.replace(/(?<!\[)@(\w+)/g, '[@$1](mention://$1)');
|
||||
return result;
|
||||
};
|
||||
|
||||
export const formatEmojis = (text, customEmojis = []) => {
|
||||
if (!text) return '';
|
||||
return text.replace(/:([a-zA-Z0-9_]+):/g, (match, name) => {
|
||||
const custom = customEmojis.find(e => e.name === name);
|
||||
if (custom) return ``;
|
||||
const emoji = AllEmojis.find(e => e.name === name);
|
||||
return emoji ? `` : match;
|
||||
});
|
||||
};
|
||||
|
||||
export const parseAttachment = (content) => {
|
||||
if (!content || !content.startsWith('{')) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
return parsed.type === 'attachment' ? parsed : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const parseSystemMessage = (content) => {
|
||||
if (!content || !content.startsWith('{')) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
return parsed.type === 'system' ? parsed : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const VIDEO_EXT_RE = /\.(mp4|webm|ogg|mov)(\?[^\s]*)?$/i;
|
||||
const isVideoUrl = (url) => VIDEO_EXT_RE.test(url);
|
||||
|
||||
const getReactionIcon = (name, customEmojis = []) => {
|
||||
const custom = customEmojis.find(e => e.name === name);
|
||||
if (custom) return custom.src;
|
||||
switch (name) {
|
||||
case 'thumbsup': return thumbsupIcon;
|
||||
case 'heart': return heartIcon;
|
||||
case 'fire': return fireIcon;
|
||||
default: {
|
||||
const builtin = AllEmojis.find(e => e.name === name);
|
||||
return builtin ? builtin.src : heartIcon;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isNewDay = (current, previous) => {
|
||||
if (!previous) return true;
|
||||
return current.getDate() !== previous.getDate()
|
||||
|| current.getMonth() !== previous.getMonth()
|
||||
|| current.getFullYear() !== previous.getFullYear();
|
||||
};
|
||||
|
||||
const createMarkdownComponents = (openExternal) => ({
|
||||
a: ({ node, ...props }) => {
|
||||
if (props.href && props.href.startsWith('rolemention://')) {
|
||||
try {
|
||||
const url = new URL(props.href);
|
||||
const color = url.searchParams.get('color') || '#99aab5';
|
||||
return <span style={{ background: `${color}26`, borderRadius: '3px', color, fontWeight: 500, padding: '0 2px', cursor: 'default' }} title={props.children}>{props.children}</span>;
|
||||
} catch {
|
||||
return <span>{props.children}</span>;
|
||||
}
|
||||
}
|
||||
if (props.href && props.href.startsWith('mention://')) return <span style={{ background: 'hsla(234.935, 85.556%, 64.706%, 0.239)', borderRadius: '3px', color: 'hsl(228.14, 100%, 83.137%)', fontWeight: 500, padding: '0 2px', cursor: 'default' }} title={props.children}>{props.children}</span>;
|
||||
return <a {...props} onClick={(e) => { e.preventDefault(); openExternal(props.href); }} style={{ color: '#00b0f4', cursor: 'pointer', textDecoration: 'none' }} onMouseOver={(e) => e.target.style.textDecoration = 'underline'} onMouseOut={(e) => e.target.style.textDecoration = 'none'} />;
|
||||
},
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
return !inline && match ? <SyntaxHighlighter style={oneDark} language={match[1]} PreTag="div" {...props}>{String(children).replace(/\n$/, '')}</SyntaxHighlighter> : <code className={className} {...props}>{children}</code>;
|
||||
},
|
||||
p: ({ node, ...props }) => <p style={{ margin: '0 0 6px 0' }} {...props} />,
|
||||
h1: ({ node, ...props }) => <h1 style={{ fontSize: '1.5rem', fontWeight: 700, margin: '12px 0 6px 0', lineHeight: 1.1 }} {...props} />,
|
||||
h2: ({ node, ...props }) => <h2 style={{ fontSize: '1.25rem', fontWeight: 700, margin: '10px 0 6px 0', lineHeight: 1.1 }} {...props} />,
|
||||
h3: ({ node, ...props }) => <h3 style={{ fontSize: '1.1rem', fontWeight: 700, margin: '8px 0 6px 0', lineHeight: 1.1 }} {...props} />,
|
||||
ul: ({ node, ...props }) => <ul style={{ margin: '0 0 6px 0', paddingLeft: '20px' }} {...props} />,
|
||||
ol: ({ node, ...props }) => <ol style={{ margin: '0 0 6px 0', paddingLeft: '20px' }} {...props} />,
|
||||
li: ({ node, ...props }) => <li style={{ margin: '0' }} {...props} />,
|
||||
hr: ({ node, ...props }) => <hr style={{ border: 'none', borderTop: '1px solid #4f545c', margin: '12px 0' }} {...props} />,
|
||||
img: ({ node, alt, src, ...props }) => {
|
||||
if (alt && alt.startsWith(':') && alt.endsWith(':')) {
|
||||
return <img src={src} alt={alt} style={{ width: '48px', height: '48px', verticalAlign: 'bottom', margin: '0 1px', display: 'inline' }} />;
|
||||
}
|
||||
return <img alt={alt} src={src} {...props} />;
|
||||
},
|
||||
});
|
||||
|
||||
const IconButton = ({ onClick, emoji }) => (
|
||||
<div onClick={(e) => { e.stopPropagation(); onClick(e); }} className="icon-button" style={{ cursor: 'pointer', padding: '6px', fontSize: '16px', lineHeight: 1, color: 'var(--header-secondary)', transition: 'background-color 0.1s' }}>
|
||||
{emoji}
|
||||
</div>
|
||||
);
|
||||
|
||||
const MessageToolbar = ({ onAddReaction, onEdit, onReply, onMore, isOwner, isAttachment }) => (
|
||||
<div className="message-toolbar">
|
||||
<Tooltip text="Thumbs Up" position="top">
|
||||
<IconButton onClick={() => onAddReaction('thumbsup')} emoji={<ColoredIcon src={thumbsupIcon} size="20px" />} />
|
||||
</Tooltip>
|
||||
<Tooltip text="Heart" position="top">
|
||||
<IconButton onClick={() => onAddReaction('heart')} emoji={<ColoredIcon src={heartIcon} size="20px" />} />
|
||||
</Tooltip>
|
||||
<Tooltip text="Fire" position="top">
|
||||
<IconButton onClick={() => onAddReaction('fire')} emoji={<ColoredIcon src={fireIcon} size="20px" />} />
|
||||
</Tooltip>
|
||||
<div style={{ width: '1px', height: '24px', margin: '2px 4px', backgroundColor: 'hsla(240, 4%, 60.784%, 0.122)' }}></div>
|
||||
<Tooltip text="Add Reaction" position="top">
|
||||
<IconButton onClick={() => onAddReaction(null)} emoji={<ColoredIcon src={EmojieIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
|
||||
</Tooltip>
|
||||
{isOwner && !isAttachment && (
|
||||
<Tooltip text="Edit" position="top">
|
||||
<IconButton onClick={onEdit} emoji={<ColoredIcon src={EditIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip text="Reply" position="top">
|
||||
<IconButton onClick={onReply} emoji={<ColoredIcon src={ReplyIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
|
||||
</Tooltip>
|
||||
<Tooltip text="More" position="top">
|
||||
<IconButton onClick={onMore} emoji={<ColoredIcon src={MoreIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MessageItem = React.memo(({
|
||||
msg,
|
||||
isGrouped,
|
||||
showDateDivider,
|
||||
showUnreadDivider,
|
||||
dateLabel,
|
||||
isMentioned,
|
||||
isOwner,
|
||||
isEditing,
|
||||
isHovered,
|
||||
editInput,
|
||||
username,
|
||||
roles,
|
||||
customEmojis,
|
||||
onHover,
|
||||
onLeave,
|
||||
onContextMenu,
|
||||
onAddReaction,
|
||||
onEdit,
|
||||
onReply,
|
||||
onMore,
|
||||
onEditInputChange,
|
||||
onEditKeyDown,
|
||||
onEditSave,
|
||||
onEditCancel,
|
||||
onReactionClick,
|
||||
onScrollToMessage,
|
||||
onProfilePopup,
|
||||
onImageClick,
|
||||
onLongPress,
|
||||
scrollToBottom,
|
||||
Attachment,
|
||||
LinkPreview,
|
||||
DirectVideo,
|
||||
}) => {
|
||||
const { links } = usePlatform();
|
||||
const markdownComponents = createMarkdownComponents(links.openExternal);
|
||||
const currentDate = new Date(msg.created_at);
|
||||
const userColor = getUserColor(msg.username || 'Unknown');
|
||||
|
||||
const systemMsg = parseSystemMessage(msg.content);
|
||||
|
||||
const renderMessageContent = () => {
|
||||
if (systemMsg) return null;
|
||||
|
||||
const attachmentMetadata = parseAttachment(msg.content);
|
||||
if (attachmentMetadata) {
|
||||
return <Attachment metadata={attachmentMetadata} onLoad={scrollToBottom} onImageClick={onImageClick} />;
|
||||
}
|
||||
|
||||
const urls = extractUrls(msg.content);
|
||||
const isOnlyUrl = urls.length === 1 && msg.content.trim() === urls[0];
|
||||
const isGif = isOnlyUrl && (urls[0].includes('tenor.com') || urls[0].includes('giphy.com') || urls[0].endsWith('.gif'));
|
||||
const isDirectVideo = isOnlyUrl && isVideoUrl(urls[0]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isGif && !isDirectVideo && (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={(url) => url} components={markdownComponents}>
|
||||
{formatEmojis(formatMentions(msg.content, roles), customEmojis)}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
{isDirectVideo && <DirectVideo src={urls[0]} marginTop={4} />}
|
||||
{urls.filter(u => !(isDirectVideo && u === urls[0])).map((url, i) => (
|
||||
<LinkPreview key={i} url={url} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderReactions = () => {
|
||||
if (!msg.reactions || Object.keys(msg.reactions).length === 0) return null;
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '4px', marginTop: '4px', flexWrap: 'wrap' }}>
|
||||
{Object.entries(msg.reactions).map(([emojiName, data]) => (
|
||||
<div key={emojiName} onClick={() => onReactionClick(msg.id, emojiName, data.me)} style={{ display: 'flex', alignItems: 'center', backgroundColor: data.me ? 'rgba(88, 101, 242, 0.15)' : 'hsla(240, 4%, 60.784%, 0.078)', border: data.me ? '1px solid var(--brand-experiment)' : '1px solid transparent', borderRadius: '8px', padding: '2px 6px', cursor: 'pointer', gap: '4px' }}>
|
||||
<ColoredIcon src={getReactionIcon(emojiName, customEmojis)} size="16px" color={null} />
|
||||
<span style={{ fontSize: '12px', color: data.me ? '#dee0fc' : 'var(--header-secondary)', fontWeight: 600 }}>{data.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{showUnreadDivider && (
|
||||
<div className="unread-divider" role="separator">
|
||||
<span className="unread-pill">
|
||||
<svg className="unread-pill-cap" width="8" height="13" viewBox="0 0 8 13">
|
||||
<path stroke="currentColor" fill="transparent" d="M8.16639 0.5H9C10.933 0.5 12.5 2.067 12.5 4V9C12.5 10.933 10.933 12.5 9 12.5H8.16639C7.23921 12.5 6.34992 12.1321 5.69373 11.4771L0.707739 6.5L5.69373 1.52292C6.34992 0.86789 7.23921 0.5 8.16639 0.5Z" />
|
||||
</svg>
|
||||
NEW
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{showDateDivider && <div className="date-divider"><span>{dateLabel}</span></div>}
|
||||
{systemMsg ? (
|
||||
<div id={`msg-${msg.id}`} className="system-message-row">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#23a559" style={{ flexShrink: 0 }}>
|
||||
<path d="M2 7.4A5.4 5.4 0 0 1 7.4 2c.36 0 .7.22.83.55l1.93 4.64a1 1 0 0 1-.43 1.25L7 10a8.52 8.52 0 0 0 7 7l1.12-2.24a1 1 0 0 1 1.19-.51l5.06 1.56c.38.11.63.46.63.85C22 19.6 19.6 22 16.66 22h-.37C8.39 22 2 15.6 2 7.71V7.4ZM13 3a1 1 0 0 1 1-1 8 8 0 0 1 8 8 1 1 0 1 1-2 0 6 6 0 0 0-6-6 1 1 0 0 1-1-1Z"/>
|
||||
<path d="M13 7a1 1 0 0 1 1-1 4 4 0 0 1 4 4 1 1 0 1 1-2 0 2 2 0 0 0-2-2 1 1 0 0 1-1-1Z"/>
|
||||
</svg>
|
||||
<span className="system-message-text">
|
||||
{systemMsg.text || 'System event'}
|
||||
</span>
|
||||
<span className="system-message-time">
|
||||
{currentDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
id={`msg-${msg.id}`}
|
||||
className={`message-item${isGrouped ? ' message-grouped' : ''}`}
|
||||
style={isMentioned ? { background: 'hsla(36.894, 100%, 31.569%, 0.078)', position: 'relative' } : { position: 'relative' }}
|
||||
onMouseEnter={onHover}
|
||||
onMouseLeave={onLeave}
|
||||
onContextMenu={onContextMenu}
|
||||
onTouchStart={onLongPress?.onTouchStart}
|
||||
onTouchMove={onLongPress?.onTouchMove}
|
||||
onTouchEnd={onLongPress?.onTouchEnd}
|
||||
>
|
||||
{isMentioned && <div style={{ background: 'hsl(34, 50.847%, 53.725%)', bottom: 0, display: 'block', left: 0, pointerEvents: 'none', position: 'absolute', top: 0, width: '2px' }} />}
|
||||
|
||||
{msg.replyToId && msg.replyToUsername && (
|
||||
<div className="message-reply-context" onClick={() => onScrollToMessage(msg.replyToId)}>
|
||||
<div className="reply-spine" />
|
||||
<Avatar username={msg.replyToUsername} avatarUrl={msg.replyToAvatarUrl} size={16} className="reply-avatar" />
|
||||
<span className="reply-author" style={{ color: getUserColor(msg.replyToUsername) }}>
|
||||
@{msg.replyToDisplayName || msg.replyToUsername}
|
||||
</span>
|
||||
<span className="reply-text">{msg.decryptedReply || '[Encrypted]'}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isGrouped ? (
|
||||
<div className="message-avatar-wrapper">
|
||||
</div>
|
||||
) : (
|
||||
<div className="message-avatar-wrapper">
|
||||
<Avatar
|
||||
username={msg.username}
|
||||
avatarUrl={msg.avatarUrl}
|
||||
size={40}
|
||||
className="message-avatar"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={(e) => onProfilePopup(e, msg)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="message-body">
|
||||
{!isGrouped && (
|
||||
<div className="message-header">
|
||||
<span
|
||||
className="username"
|
||||
style={{ color: userColor, cursor: 'pointer' }}
|
||||
onClick={(e) => onProfilePopup(e, msg)}
|
||||
>
|
||||
{msg.displayName || msg.username || 'Unknown'}
|
||||
</span>
|
||||
{msg.isVerified === false && <span className="verification-failed" title="Signature Verification Failed!"><svg aria-hidden="true" role="img" width="16" height="16" viewBox="0 0 24 24" fill="var(--danger)"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path></svg></span>}
|
||||
<span className="timestamp">{currentDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'relative' }}>
|
||||
{isEditing ? (
|
||||
<div className="message-editing">
|
||||
<textarea
|
||||
className="message-edit-textarea"
|
||||
value={editInput}
|
||||
onChange={onEditInputChange}
|
||||
onKeyDown={onEditKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="message-edit-hint">
|
||||
escape to <span onClick={onEditCancel}>cancel</span> · enter to <span onClick={onEditSave}>save</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="message-content">
|
||||
{renderMessageContent()}
|
||||
{msg.editedAt && <span className="edited-indicator">(edited)</span>}
|
||||
{renderReactions()}
|
||||
</div>
|
||||
)}
|
||||
{isHovered && !isEditing && (
|
||||
<MessageToolbar isOwner={isOwner}
|
||||
isAttachment={!!parseAttachment(msg.content)}
|
||||
onAddReaction={onAddReaction}
|
||||
onEdit={onEdit}
|
||||
onReply={onReply}
|
||||
onMore={onMore}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.msg.id === nextProps.msg.id &&
|
||||
prevProps.msg.content === nextProps.msg.content &&
|
||||
prevProps.msg.editedAt === nextProps.msg.editedAt &&
|
||||
prevProps.msg.reactions === nextProps.msg.reactions &&
|
||||
prevProps.msg.decryptedReply === nextProps.msg.decryptedReply &&
|
||||
prevProps.msg.isVerified === nextProps.msg.isVerified &&
|
||||
prevProps.isHovered === nextProps.isHovered &&
|
||||
prevProps.isEditing === nextProps.isEditing &&
|
||||
prevProps.editInput === nextProps.editInput &&
|
||||
prevProps.isGrouped === nextProps.isGrouped &&
|
||||
prevProps.showDateDivider === nextProps.showDateDivider &&
|
||||
prevProps.showUnreadDivider === nextProps.showUnreadDivider &&
|
||||
prevProps.isMentioned === nextProps.isMentioned &&
|
||||
prevProps.roles === nextProps.roles &&
|
||||
prevProps.customEmojis === nextProps.customEmojis
|
||||
);
|
||||
});
|
||||
|
||||
MessageItem.displayName = 'MessageItem';
|
||||
|
||||
export default MessageItem;
|
||||
@@ -1,126 +0,0 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import ColoredIcon from './ColoredIcon';
|
||||
import settingsIcon from '../assets/icons/settings.svg';
|
||||
|
||||
const ICON_COLOR_DEFAULT = 'hsl(240, 4.294%, 68.039%)';
|
||||
|
||||
const MobileChannelDrawer = ({ channel, isUnread, onMarkAsRead, onEditChannel, onClose }) => {
|
||||
const [closing, setClosing] = useState(false);
|
||||
const drawerRef = useRef(null);
|
||||
const dragStartY = useRef(null);
|
||||
const dragCurrentY = useRef(null);
|
||||
const dragStartTime = useRef(null);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setClosing(true);
|
||||
setTimeout(onClose, 200);
|
||||
}, [onClose]);
|
||||
|
||||
const handleAction = useCallback((cb) => {
|
||||
dismiss();
|
||||
setTimeout(cb, 220);
|
||||
}, [dismiss]);
|
||||
|
||||
// Swipe-to-dismiss
|
||||
const handleTouchStart = useCallback((e) => {
|
||||
dragStartY.current = e.touches[0].clientY;
|
||||
dragCurrentY.current = e.touches[0].clientY;
|
||||
dragStartTime.current = Date.now();
|
||||
if (drawerRef.current) {
|
||||
drawerRef.current.style.transition = 'none';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback((e) => {
|
||||
if (dragStartY.current === null) return;
|
||||
dragCurrentY.current = e.touches[0].clientY;
|
||||
const dy = dragCurrentY.current - dragStartY.current;
|
||||
if (dy > 0 && drawerRef.current) {
|
||||
drawerRef.current.style.transform = `translateY(${dy}px)`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (dragStartY.current === null || !drawerRef.current) return;
|
||||
const dy = dragCurrentY.current - dragStartY.current;
|
||||
const dt = (Date.now() - dragStartTime.current) / 1000;
|
||||
const velocity = dt > 0 ? dy / dt : 0;
|
||||
const drawerHeight = drawerRef.current.offsetHeight;
|
||||
const threshold = drawerHeight * 0.3;
|
||||
|
||||
if (dy > threshold || velocity > 500) {
|
||||
dismiss();
|
||||
} else {
|
||||
drawerRef.current.style.transition = 'transform 0.2s ease-out';
|
||||
drawerRef.current.style.transform = 'translateY(0)';
|
||||
}
|
||||
dragStartY.current = null;
|
||||
}, [dismiss]);
|
||||
|
||||
const isVoice = channel?.type === 'voice';
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<>
|
||||
<div className="mobile-drawer-overlay" onClick={dismiss} />
|
||||
<div
|
||||
ref={drawerRef}
|
||||
className={`mobile-drawer${closing ? ' mobile-drawer-closing' : ''}`}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<div className="mobile-drawer-handle">
|
||||
<div className="mobile-drawer-handle-bar" />
|
||||
</div>
|
||||
|
||||
{/* Channel header */}
|
||||
<div style={{
|
||||
padding: '4px 16px 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}>
|
||||
{isVoice ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--interactive-normal)">
|
||||
<path d="M11.383 3.07904C11.009 2.92504 10.579 3.01004 10.293 3.29604L6.586 7.00304H3C2.45 7.00304 2 7.45304 2 8.00304V16.003C2 16.553 2.45 17.003 3 17.003H6.586L10.293 20.71C10.579 20.996 11.009 21.082 11.383 20.927C11.757 20.772 12 20.407 12 20.003V4.00304C12 3.59904 11.757 3.23404 11.383 3.07904ZM14 5.00304V7.00304C16.757 7.00304 19 9.24604 19 12.003C19 14.76 16.757 17.003 14 17.003V19.003C17.86 19.003 21 15.863 21 12.003C21 8.14304 17.86 5.00304 14 5.00304ZM14 9.00304V15.003C15.654 15.003 17 13.657 17 12.003C17 10.349 15.654 9.00304 14 9.00304Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<span style={{ color: 'var(--interactive-normal)', fontSize: 20, fontWeight: 500 }}>#</span>
|
||||
)}
|
||||
<span style={{
|
||||
color: 'var(--text-normal)',
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{channel?.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mobile-drawer-card">
|
||||
<button
|
||||
className={`mobile-drawer-action${!isUnread ? ' mobile-drawer-action-disabled' : ''}`}
|
||||
onClick={isUnread ? () => handleAction(onMarkAsRead) : undefined}
|
||||
disabled={!isUnread}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill={isUnread ? ICON_COLOR_DEFAULT : 'var(--text-muted)'}>
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||
</svg>
|
||||
<span>Mark As Read</span>
|
||||
</button>
|
||||
<button className="mobile-drawer-action" onClick={() => handleAction(onEditChannel)}>
|
||||
<ColoredIcon src={settingsIcon} color={ICON_COLOR_DEFAULT} size="20px" />
|
||||
<span>Edit Channel</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileChannelDrawer;
|
||||
@@ -1,227 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useConvex } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
|
||||
const MobileChannelSettingsScreen = ({ channel, categories, onClose, onDelete }) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [channelName, setChannelName] = useState(channel.name);
|
||||
const [channelTopic, setChannelTopic] = useState(channel.topic || '');
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState(channel.categoryId || null);
|
||||
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const convex = useConvex();
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => setVisible(true));
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
setTimeout(onClose, 250);
|
||||
};
|
||||
|
||||
const handleNameChange = (e) => {
|
||||
setChannelName(e.target.value.toLowerCase().replace(/\s+/g, '-'));
|
||||
};
|
||||
|
||||
const hasChanges =
|
||||
channelName.trim() !== channel.name ||
|
||||
channelTopic.trim() !== (channel.topic || '') ||
|
||||
selectedCategoryId !== (channel.categoryId || null);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!hasChanges || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const trimmedName = channelName.trim();
|
||||
if (trimmedName && trimmedName !== channel.name) {
|
||||
await convex.mutation(api.channels.rename, { id: channel._id, name: trimmedName });
|
||||
}
|
||||
const trimmedTopic = channelTopic.trim();
|
||||
if (trimmedTopic !== (channel.topic || '')) {
|
||||
await convex.mutation(api.channels.updateTopic, { id: channel._id, topic: trimmedTopic });
|
||||
}
|
||||
if (selectedCategoryId !== (channel.categoryId || null)) {
|
||||
await convex.mutation(api.channels.moveChannel, {
|
||||
id: channel._id,
|
||||
categoryId: selectedCategoryId || undefined,
|
||||
position: 0,
|
||||
});
|
||||
}
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to save channel settings:', err);
|
||||
alert('Failed to save: ' + err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await convex.mutation(api.channels.remove, { id: channel._id });
|
||||
if (onDelete) onDelete(channel._id);
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete channel:', err);
|
||||
alert('Failed to delete: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const currentCategoryName = selectedCategoryId
|
||||
? (categories || []).find(c => c._id === selectedCategoryId)?.name || 'Unknown'
|
||||
: 'None';
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className={`mobile-create-screen${visible ? ' visible' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="mobile-create-header">
|
||||
<button className="mobile-create-close-btn" onClick={handleClose}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="mobile-create-title">Channel Settings</span>
|
||||
<button
|
||||
className={`mobile-create-submit-btn${!hasChanges || saving ? ' disabled' : ''}`}
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="mobile-create-body">
|
||||
{/* Channel Name */}
|
||||
<div className="mobile-create-section">
|
||||
<label className="mobile-create-section-label">Channel Name</label>
|
||||
<div className="mobile-create-input-wrapper">
|
||||
<span className="mobile-create-input-prefix">
|
||||
{channel.type === 'voice' ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 3a1 1 0 0 0-1-1h-.06a1 1 0 0 0-.74.32L5.92 7H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.92l4.28 4.68a1 1 0 0 0 .74.32H11a1 1 0 0 0 1-1z" />
|
||||
</svg>
|
||||
) : '#'}
|
||||
</span>
|
||||
<input
|
||||
className="mobile-create-input"
|
||||
type="text"
|
||||
placeholder="channel-name"
|
||||
value={channelName}
|
||||
onChange={handleNameChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel Topic */}
|
||||
<div className="mobile-create-section">
|
||||
<label className="mobile-create-section-label">
|
||||
Channel Topic
|
||||
<span style={{ float: 'right', fontWeight: 400, textTransform: 'none' }}>
|
||||
{channelTopic.length}/1024
|
||||
</span>
|
||||
</label>
|
||||
<div className="mobile-create-input-wrapper" style={{ alignItems: 'flex-start' }}>
|
||||
<textarea
|
||||
className="mobile-create-input"
|
||||
placeholder="Set a topic for this channel"
|
||||
value={channelTopic}
|
||||
onChange={(e) => {
|
||||
if (e.target.value.length <= 1024) setChannelTopic(e.target.value);
|
||||
}}
|
||||
rows={3}
|
||||
style={{
|
||||
resize: 'none',
|
||||
fontFamily: 'inherit',
|
||||
lineHeight: '1.4',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div className="mobile-create-section">
|
||||
<label className="mobile-create-section-label">Category</label>
|
||||
<div
|
||||
className="mobile-create-input-wrapper"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setShowCategoryPicker(!showCategoryPicker)}
|
||||
>
|
||||
<span className="mobile-create-input" style={{ cursor: 'pointer', userSelect: 'none' }}>
|
||||
{currentCategoryName}
|
||||
</span>
|
||||
<svg
|
||||
width="20" height="20" viewBox="0 0 24 24"
|
||||
fill="var(--interactive-normal)"
|
||||
style={{
|
||||
marginRight: 8,
|
||||
flexShrink: 0,
|
||||
transform: showCategoryPicker ? 'rotate(180deg)' : 'none',
|
||||
transition: 'transform 0.15s',
|
||||
}}
|
||||
>
|
||||
<path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z" />
|
||||
</svg>
|
||||
</div>
|
||||
{showCategoryPicker && (
|
||||
<div className="mobile-channel-settings-category-list">
|
||||
<div
|
||||
className={`mobile-channel-settings-category-option${!selectedCategoryId ? ' selected' : ''}`}
|
||||
onClick={() => { setSelectedCategoryId(null); setShowCategoryPicker(false); }}
|
||||
>
|
||||
None
|
||||
</div>
|
||||
{(categories || []).map(cat => (
|
||||
<div
|
||||
key={cat._id}
|
||||
className={`mobile-channel-settings-category-option${selectedCategoryId === cat._id ? ' selected' : ''}`}
|
||||
onClick={() => { setSelectedCategoryId(cat._id); setShowCategoryPicker(false); }}
|
||||
>
|
||||
{cat.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Channel */}
|
||||
<div className="mobile-create-section" style={{ marginTop: 16 }}>
|
||||
{!confirmDelete ? (
|
||||
<button
|
||||
className="mobile-channel-settings-delete-btn"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
>
|
||||
Delete Channel
|
||||
</button>
|
||||
) : (
|
||||
<div className="mobile-channel-settings-delete-confirm">
|
||||
<p style={{ color: '#ed4245', fontSize: 14, margin: '0 0 12px' }}>
|
||||
Are you sure you want to delete <strong>#{channel.name}</strong>? This cannot be undone.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="mobile-channel-settings-cancel-btn"
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="mobile-channel-settings-delete-btn"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileChannelSettingsScreen;
|
||||
@@ -1,82 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
const MobileCreateCategoryScreen = ({ onClose, onSubmit }) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [categoryName, setCategoryName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => setVisible(true));
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
setTimeout(onClose, 250);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!categoryName.trim()) return;
|
||||
onSubmit(categoryName.trim());
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className={`mobile-create-screen${visible ? ' visible' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="mobile-create-header">
|
||||
<button className="mobile-create-close-btn" onClick={handleClose}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="mobile-create-title">Create Category</span>
|
||||
<button
|
||||
className={`mobile-create-submit-btn${!categoryName.trim() ? ' disabled' : ''}`}
|
||||
onClick={handleCreate}
|
||||
disabled={!categoryName.trim()}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="mobile-create-body">
|
||||
{/* Category Name */}
|
||||
<div className="mobile-create-section">
|
||||
<label className="mobile-create-section-label">Category Name</label>
|
||||
<div className="mobile-create-input-wrapper">
|
||||
<input
|
||||
className="mobile-create-input"
|
||||
type="text"
|
||||
placeholder="New Category"
|
||||
value={categoryName}
|
||||
onChange={(e) => setCategoryName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Private Category Toggle */}
|
||||
<div className="mobile-create-section">
|
||||
<p className="mobile-create-private-desc">
|
||||
By making a category private, only selected members and roles will be able to view this category.
|
||||
</p>
|
||||
<div className="mobile-create-toggle-row">
|
||||
<div className="mobile-create-toggle-left">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--interactive-normal)' }}>
|
||||
<path d="M17 11V7C17 4.243 14.757 2 12 2C9.243 2 7 4.243 7 7V11C5.897 11 5 11.896 5 13V20C5 21.103 5.897 22 7 22H17C18.103 22 19 21.103 19 20V13C19 11.896 18.103 11 17 11ZM12 18C11.172 18 10.5 17.328 10.5 16.5C10.5 15.672 11.172 15 12 15C12.828 15 13.5 15.672 13.5 16.5C13.5 17.328 12.828 18 12 18ZM15 11H9V7C9 5.346 10.346 4 12 4C13.654 4 15 5.346 15 7V11Z" />
|
||||
</svg>
|
||||
<span className="mobile-create-toggle-label">Private Category</span>
|
||||
</div>
|
||||
<div className="category-toggle-switch">
|
||||
<div className="category-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileCreateCategoryScreen;
|
||||
@@ -1,128 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
const MobileCreateChannelScreen = ({ onClose, onSubmit, categoryId }) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [channelName, setChannelName] = useState('');
|
||||
const [channelType, setChannelType] = useState('text');
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => setVisible(true));
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
setTimeout(onClose, 250);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!channelName.trim()) return;
|
||||
onSubmit(channelName.trim(), channelType, categoryId);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleNameChange = (e) => {
|
||||
setChannelName(e.target.value.toLowerCase().replace(/\s+/g, '-'));
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className={`mobile-create-screen${visible ? ' visible' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="mobile-create-header">
|
||||
<button className="mobile-create-close-btn" onClick={handleClose}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="mobile-create-title">Create Channel</span>
|
||||
<button
|
||||
className={`mobile-create-submit-btn${!channelName.trim() ? ' disabled' : ''}`}
|
||||
onClick={handleCreate}
|
||||
disabled={!channelName.trim()}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="mobile-create-body">
|
||||
{/* Channel Name */}
|
||||
<div className="mobile-create-section">
|
||||
<label className="mobile-create-section-label">Channel Name</label>
|
||||
<div className="mobile-create-input-wrapper">
|
||||
<span className="mobile-create-input-prefix">
|
||||
{channelType === 'text' ? '#' : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 3a1 1 0 0 0-1-1h-.06a1 1 0 0 0-.74.32L5.92 7H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.92l4.28 4.68a1 1 0 0 0 .74.32H11a1 1 0 0 0 1-1z" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<input
|
||||
className="mobile-create-input"
|
||||
type="text"
|
||||
placeholder="new-channel"
|
||||
value={channelName}
|
||||
onChange={handleNameChange}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel Type */}
|
||||
<div className="mobile-create-section">
|
||||
<label className="mobile-create-section-label">Channel Type</label>
|
||||
<div className="mobile-create-type-list">
|
||||
<div
|
||||
className={`mobile-create-type-option${channelType === 'text' ? ' selected' : ''}`}
|
||||
onClick={() => setChannelType('text')}
|
||||
>
|
||||
<span className="mobile-create-type-icon">#</span>
|
||||
<div className="mobile-create-type-info">
|
||||
<div className="mobile-create-type-name">Text</div>
|
||||
<div className="mobile-create-type-desc">Send messages, images, GIFs, emoji, opinions, and puns</div>
|
||||
</div>
|
||||
<div className={`mobile-create-radio${channelType === 'text' ? ' selected' : ''}`}>
|
||||
{channelType === 'text' && <div className="mobile-create-radio-dot" />}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`mobile-create-type-option${channelType === 'voice' ? ' selected' : ''}`}
|
||||
onClick={() => setChannelType('voice')}
|
||||
>
|
||||
<span className="mobile-create-type-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.383 3.07904C11.009 2.92504 10.579 3.01004 10.293 3.29604L6.586 7.00304H3C2.45 7.00304 2 7.45304 2 8.00304V16.003C2 16.553 2.45 17.003 3 17.003H6.586L10.293 20.71C10.579 20.996 11.009 21.082 11.383 20.927C11.757 20.772 12 20.407 12 20.003V4.00304C12 3.59904 11.757 3.23404 11.383 3.07904ZM14 5.00304V7.00304C16.757 7.00304 19 9.24604 19 12.003C19 14.76 16.757 17.003 14 17.003V19.003C17.86 19.003 21 15.863 21 12.003C21 8.14304 17.86 5.00304 14 5.00304ZM14 9.00304V15.003C15.654 15.003 17 13.657 17 12.003C17 10.349 15.654 9.00304 14 9.00304Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div className="mobile-create-type-info">
|
||||
<div className="mobile-create-type-name">Voice</div>
|
||||
<div className="mobile-create-type-desc">Hang out together with voice, video, and screen share</div>
|
||||
</div>
|
||||
<div className={`mobile-create-radio${channelType === 'voice' ? ' selected' : ''}`}>
|
||||
{channelType === 'voice' && <div className="mobile-create-radio-dot" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Private Channel Toggle */}
|
||||
<div className="mobile-create-section">
|
||||
<div className="mobile-create-toggle-row">
|
||||
<div className="mobile-create-toggle-left">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--interactive-normal)' }}>
|
||||
<path d="M17 11V7C17 4.243 14.757 2 12 2C9.243 2 7 4.243 7 7V11C5.897 11 5 11.896 5 13V20C5 21.103 5.897 22 7 22H17C18.103 22 19 21.103 19 20V13C19 11.896 18.103 11 17 11ZM12 18C11.172 18 10.5 17.328 10.5 16.5C10.5 15.672 11.172 15 12 15C12.828 15 13.5 15.672 13.5 16.5C13.5 17.328 12.828 18 12 18ZM15 11H9V7C9 5.346 10.346 4 12 4C13.654 4 15 5.346 15 7V11Z" />
|
||||
</svg>
|
||||
<span className="mobile-create-toggle-label">Private Channel</span>
|
||||
</div>
|
||||
<div className="category-toggle-switch">
|
||||
<div className="category-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileCreateChannelScreen;
|
||||
@@ -1,208 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import { useOnlineUsers } from '../contexts/PresenceContext';
|
||||
import { useVoice } from '../contexts/VoiceContext';
|
||||
import { CrownIcon, SharingIcon } from '../assets/icons';
|
||||
import ColoredIcon from './ColoredIcon';
|
||||
|
||||
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||
|
||||
function getUserColor(name) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
|
||||
}
|
||||
|
||||
const STATUS_COLORS = {
|
||||
online: '#3ba55c',
|
||||
idle: '#faa61a',
|
||||
dnd: '#ed4245',
|
||||
invisible: '#747f8d',
|
||||
offline: '#747f8d',
|
||||
};
|
||||
|
||||
const TABS = ['Members', 'Media', 'Pins', 'Threads', 'Links', 'Files'];
|
||||
|
||||
const MobileMembersScreen = ({ channelId, channelName, onClose }) => {
|
||||
const [activeTab, setActiveTab] = useState('Members');
|
||||
const [visible, setVisible] = useState(false);
|
||||
const members = useQuery(
|
||||
api.members.getChannelMembers,
|
||||
channelId ? { channelId } : "skip"
|
||||
) || [];
|
||||
const { resolveStatus } = useOnlineUsers();
|
||||
const { voiceStates } = useVoice();
|
||||
|
||||
const usersInVoice = new Set();
|
||||
const usersScreenSharing = new Set();
|
||||
Object.values(voiceStates).forEach(users => {
|
||||
users.forEach(u => {
|
||||
usersInVoice.add(u.userId);
|
||||
if (u.isScreenSharing) usersScreenSharing.add(u.userId);
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => setVisible(true));
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
setTimeout(onClose, 250);
|
||||
};
|
||||
|
||||
const onlineMembers = members.filter(m => resolveStatus(m.status, m.id) !== 'offline');
|
||||
const offlineMembers = members.filter(m => resolveStatus(m.status, m.id) === 'offline');
|
||||
|
||||
const roleGroups = {};
|
||||
const ungrouped = [];
|
||||
|
||||
onlineMembers.forEach(member => {
|
||||
const hoistedRole = member.roles.find(r => r.isHoist && r.name !== '@everyone' && r.name !== 'Owner');
|
||||
if (hoistedRole) {
|
||||
const key = `${hoistedRole.position}_${hoistedRole.name}`;
|
||||
if (!roleGroups[key]) {
|
||||
roleGroups[key] = { role: hoistedRole, members: [] };
|
||||
}
|
||||
roleGroups[key].members.push(member);
|
||||
} else {
|
||||
ungrouped.push(member);
|
||||
}
|
||||
});
|
||||
|
||||
const sortedGroups = Object.values(roleGroups).sort((a, b) => b.role.position - a.role.position);
|
||||
|
||||
const renderMember = (member) => {
|
||||
const displayRole = member.roles.find(r => r.name !== '@everyone' && r.name !== 'Owner') || null;
|
||||
const nameColor = displayRole ? displayRole.color : '#fff';
|
||||
const isOwner = member.roles.some(r => r.name === 'Owner');
|
||||
const effectiveStatus = resolveStatus(member.status, member.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className="mobile-member-item"
|
||||
style={effectiveStatus === 'offline' ? { opacity: 0.3 } : {}}
|
||||
>
|
||||
<div className="member-avatar-wrapper">
|
||||
{member.avatarUrl ? (
|
||||
<img
|
||||
className="member-avatar"
|
||||
src={member.avatarUrl}
|
||||
alt={member.username}
|
||||
style={{ objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="member-avatar"
|
||||
style={{ backgroundColor: getUserColor(member.username) }}
|
||||
>
|
||||
{(member.displayName || member.username).substring(0, 1).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="member-status-dot"
|
||||
style={{ backgroundColor: STATUS_COLORS[effectiveStatus] || STATUS_COLORS.offline }}
|
||||
/>
|
||||
</div>
|
||||
<div className="member-info">
|
||||
<span className="member-name" style={{ color: nameColor, display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
{member.displayName || member.username}
|
||||
{isOwner && <ColoredIcon src={CrownIcon} color="var(--text-feedback-warning)" size="14px" />}
|
||||
</span>
|
||||
{usersScreenSharing.has(member.id) ? (
|
||||
<div className="member-screen-sharing-indicator">
|
||||
<img src={SharingIcon} alt="" />
|
||||
Sharing their screen
|
||||
</div>
|
||||
) : usersInVoice.has(member.id) ? (
|
||||
<div className="member-voice-indicator">
|
||||
<svg viewBox="0 0 24 24" fill="#3ba55c">
|
||||
<path d="M12 3a1 1 0 0 0-1-1h-.06a1 1 0 0 0-.74.32L5.92 7H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.92l4.28 4.68a1 1 0 0 0 .74.32H11a1 1 0 0 0 1-1zm3.1 17.75c-.58.14-1.1-.33-1.1-.92v-.03c0-.5.37-.92.85-1.05a7 7 0 0 0 0-13.5A1.11 1.11 0 0 1 14 4.2v-.03c0-.6.52-1.06 1.1-.92a9 9 0 0 1 0 17.5" />
|
||||
<path d="M15.16 16.51c-.57.28-1.16-.2-1.16-.83v-.14c0-.43.28-.8.63-1.02a3 3 0 0 0 0-5.04c-.35-.23-.63-.6-.63-1.02v-.14c0-.63.59-1.1 1.16-.83a5 5 0 0 1 0 9.02" />
|
||||
</svg>
|
||||
In Voice
|
||||
</div>
|
||||
) : member.customStatus ? (
|
||||
<div style={{ fontSize: '12px', color: 'var(--text-muted)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{member.customStatus}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className={`mobile-members-screen${visible ? ' visible' : ''}`}>
|
||||
<div className="mobile-members-header">
|
||||
<button className="mobile-members-back" onClick={handleClose}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="mobile-members-header-info">
|
||||
<div className="mobile-members-header-name">
|
||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>#</span>
|
||||
{channelName}
|
||||
</div>
|
||||
<div className="mobile-members-header-subtitle">Text Channel</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mobile-members-tabs">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
className={`mobile-members-tab${activeTab === tab ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mobile-members-content">
|
||||
{activeTab === 'Members' ? (
|
||||
<>
|
||||
{sortedGroups.map(group => (
|
||||
<React.Fragment key={group.role.name}>
|
||||
<div className="members-role-header">
|
||||
{group.role.name} — {group.members.length}
|
||||
</div>
|
||||
{group.members.map(renderMember)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{ungrouped.length > 0 && (
|
||||
<>
|
||||
<div className="members-role-header">
|
||||
ONLINE — {ungrouped.length}
|
||||
</div>
|
||||
{ungrouped.map(renderMember)}
|
||||
</>
|
||||
)}
|
||||
{offlineMembers.length > 0 && (
|
||||
<>
|
||||
<div className="members-role-header">
|
||||
OFFLINE — {offlineMembers.length}
|
||||
</div>
|
||||
{offlineMembers.map(renderMember)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="mobile-members-placeholder">
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}>
|
||||
{activeTab} coming soon
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileMembersScreen;
|
||||
@@ -1,158 +0,0 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { EmojieIcon, EditIcon, ReplyIcon, DeleteIcon, PinIcon } from '../assets/icons';
|
||||
import { getEmojiUrl } from '../assets/emojis';
|
||||
import ColoredIcon from './ColoredIcon';
|
||||
|
||||
const ICON_COLOR_DEFAULT = 'hsl(240, 4.294%, 68.039%)';
|
||||
|
||||
const QUICK_REACTIONS = [
|
||||
{ name: 'thumbsup', category: 'people' },
|
||||
{ name: 'fire', category: 'nature' },
|
||||
{ name: 'heart', category: 'symbols' },
|
||||
{ name: 'joy', category: 'people' },
|
||||
{ name: 'sob', category: 'people' },
|
||||
{ name: 'eyes', category: 'people' },
|
||||
];
|
||||
|
||||
const CopyIcon = () => (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MobileMessageDrawer = ({ message, isOwner, isAttachment, canDelete, onClose, onAction, onQuickReaction }) => {
|
||||
const [closing, setClosing] = useState(false);
|
||||
const drawerRef = useRef(null);
|
||||
const dragStartY = useRef(null);
|
||||
const dragCurrentY = useRef(null);
|
||||
const dragStartTime = useRef(null);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setClosing(true);
|
||||
setTimeout(onClose, 200);
|
||||
}, [onClose]);
|
||||
|
||||
const handleAction = useCallback((action) => {
|
||||
onAction(action);
|
||||
dismiss();
|
||||
}, [onAction, dismiss]);
|
||||
|
||||
const handleQuickReaction = useCallback((name) => {
|
||||
onQuickReaction(name);
|
||||
dismiss();
|
||||
}, [onQuickReaction, dismiss]);
|
||||
|
||||
// Swipe-to-dismiss
|
||||
const handleTouchStart = useCallback((e) => {
|
||||
dragStartY.current = e.touches[0].clientY;
|
||||
dragCurrentY.current = e.touches[0].clientY;
|
||||
dragStartTime.current = Date.now();
|
||||
if (drawerRef.current) {
|
||||
drawerRef.current.style.transition = 'none';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback((e) => {
|
||||
if (dragStartY.current === null) return;
|
||||
dragCurrentY.current = e.touches[0].clientY;
|
||||
const dy = dragCurrentY.current - dragStartY.current;
|
||||
if (dy > 0 && drawerRef.current) {
|
||||
drawerRef.current.style.transform = `translateY(${dy}px)`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (dragStartY.current === null || !drawerRef.current) return;
|
||||
const dy = dragCurrentY.current - dragStartY.current;
|
||||
const dt = (Date.now() - dragStartTime.current) / 1000;
|
||||
const velocity = dt > 0 ? dy / dt : 0;
|
||||
const drawerHeight = drawerRef.current.offsetHeight;
|
||||
const threshold = drawerHeight * 0.3;
|
||||
|
||||
if (dy > threshold || velocity > 500) {
|
||||
dismiss();
|
||||
} else {
|
||||
drawerRef.current.style.transition = 'transform 0.2s ease-out';
|
||||
drawerRef.current.style.transform = 'translateY(0)';
|
||||
}
|
||||
dragStartY.current = null;
|
||||
}, [dismiss]);
|
||||
|
||||
const isPinned = message?.pinned;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<>
|
||||
<div className="mobile-drawer-overlay" onClick={dismiss} />
|
||||
<div
|
||||
ref={drawerRef}
|
||||
className={`mobile-drawer${closing ? ' mobile-drawer-closing' : ''}`}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<div className="mobile-drawer-handle">
|
||||
<div className="mobile-drawer-handle-bar" />
|
||||
</div>
|
||||
|
||||
{/* Quick reactions */}
|
||||
<div className="mobile-drawer-reactions">
|
||||
{QUICK_REACTIONS.map(({ name, category }) => (
|
||||
<button
|
||||
key={name}
|
||||
className="mobile-drawer-reaction-btn"
|
||||
onClick={() => handleQuickReaction(name)}
|
||||
>
|
||||
<ColoredIcon src={getEmojiUrl(category, name)} size="24px" color={null} />
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="mobile-drawer-reaction-btn"
|
||||
onClick={() => handleAction('reaction')}
|
||||
>
|
||||
<ColoredIcon src={EmojieIcon} color={ICON_COLOR_DEFAULT} size="22px" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mobile-drawer-separator" />
|
||||
|
||||
{/* Primary actions */}
|
||||
<div className="mobile-drawer-card">
|
||||
{isOwner && !isAttachment && (
|
||||
<button className="mobile-drawer-action" onClick={() => handleAction('edit')}>
|
||||
<ColoredIcon src={EditIcon} color={ICON_COLOR_DEFAULT} size="20px" />
|
||||
<span>Edit Message</span>
|
||||
</button>
|
||||
)}
|
||||
<button className="mobile-drawer-action" onClick={() => handleAction('reply')}>
|
||||
<ColoredIcon src={ReplyIcon} color={ICON_COLOR_DEFAULT} size="20px" />
|
||||
<span>Reply</span>
|
||||
</button>
|
||||
{!isAttachment && (
|
||||
<button className="mobile-drawer-action" onClick={() => handleAction('copy')}>
|
||||
<CopyIcon />
|
||||
<span>Copy Text</span>
|
||||
</button>
|
||||
)}
|
||||
<button className="mobile-drawer-action" onClick={() => handleAction('pin')}>
|
||||
<ColoredIcon src={PinIcon} color={ICON_COLOR_DEFAULT} size="20px" />
|
||||
<span>{isPinned ? 'Unpin Message' : 'Pin Message'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Danger actions */}
|
||||
{canDelete && (
|
||||
<div className="mobile-drawer-card">
|
||||
<button className="mobile-drawer-action mobile-drawer-action-danger" onClick={() => handleAction('delete')}>
|
||||
<ColoredIcon src={DeleteIcon} color="#ed4245" size="20px" />
|
||||
<span>Delete Message</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileMessageDrawer;
|
||||
@@ -1,464 +0,0 @@
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import { useOnlineUsers } from '../contexts/PresenceContext';
|
||||
import { useSearch } from '../contexts/SearchContext';
|
||||
import { usePlatform } from '../platform';
|
||||
import { LinkPreview } from './ChatArea';
|
||||
import { extractUrls } from './MessageItem';
|
||||
import Avatar from './Avatar';
|
||||
import {
|
||||
formatTime, escapeHtml, linkifyHtml, formatEmojisHtml, getAvatarColor,
|
||||
SearchResultImage, SearchResultVideo, SearchResultFile
|
||||
} from '../utils/searchRendering';
|
||||
|
||||
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||
|
||||
function getUserColor(name) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
|
||||
}
|
||||
|
||||
const STATUS_COLORS = {
|
||||
online: '#3ba55c',
|
||||
idle: '#faa61a',
|
||||
dnd: '#ed4245',
|
||||
invisible: '#747f8d',
|
||||
offline: '#747f8d',
|
||||
};
|
||||
|
||||
const BROWSE_TABS = ['Recent', 'Members', 'Channels'];
|
||||
const SEARCH_TABS = ['Messages', 'Media', 'Links', 'Files'];
|
||||
|
||||
function formatTimeAgo(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return 'Active just now';
|
||||
if (minutes < 60) return `Active ${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `Active ${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days === 1) return 'Active 1d ago';
|
||||
return `Active ${days}d ago`;
|
||||
}
|
||||
|
||||
const MobileSearchScreen = ({ channels, allMembers, serverName, onClose, onSelectChannel, onJumpToMessage }) => {
|
||||
const [activeTab, setActiveTab] = useState('Recent');
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const { resolveStatus } = useOnlineUsers();
|
||||
const { search, isReady } = useSearch() || {};
|
||||
const { links } = usePlatform();
|
||||
const customEmojis = useQuery(api.customEmojis.list) || [];
|
||||
|
||||
// Search result state
|
||||
const [messageResults, setMessageResults] = useState([]);
|
||||
const [mediaResults, setMediaResults] = useState([]);
|
||||
const [linkResults, setLinkResults] = useState([]);
|
||||
const [fileResults, setFileResults] = useState([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const searchTimerRef = useRef(null);
|
||||
|
||||
const channelIds = useMemo(() => channels.map(c => c._id), [channels]);
|
||||
const latestTimestampsRaw = useQuery(
|
||||
api.readState.getLatestMessageTimestamps,
|
||||
channelIds.length > 0 ? { channelIds } : "skip"
|
||||
) || [];
|
||||
const latestTimestamps = useMemo(() => {
|
||||
const map = {};
|
||||
for (const item of latestTimestampsRaw) {
|
||||
map[item.channelId] = item.latestTimestamp;
|
||||
}
|
||||
return map;
|
||||
}, [latestTimestampsRaw]);
|
||||
|
||||
const serverChannelIds = useMemo(() => new Set(channels.map(c => c._id)), [channels]);
|
||||
|
||||
const channelMap = useMemo(() => {
|
||||
const map = {};
|
||||
for (const c of channels) map[c._id] = c.name;
|
||||
return map;
|
||||
}, [channels]);
|
||||
|
||||
// Determine mode based on search text
|
||||
const hasQuery = searchText.trim().length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => setVisible(true));
|
||||
}, []);
|
||||
|
||||
// Debounced search execution
|
||||
useEffect(() => {
|
||||
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
||||
|
||||
if (!hasQuery || !search || !isReady) {
|
||||
setMessageResults([]);
|
||||
setMediaResults([]);
|
||||
setLinkResults([]);
|
||||
setFileResults([]);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
const q = searchText.trim();
|
||||
const filterToServer = (results) => results.filter(r => serverChannelIds.has(r.channel_id));
|
||||
|
||||
// Messages search
|
||||
const msgs = filterToServer(search({ query: q, limit: 50 }));
|
||||
msgs.sort((a, b) => b.created_at - a.created_at);
|
||||
setMessageResults(msgs);
|
||||
|
||||
// Media search (images + videos, deduped)
|
||||
const images = filterToServer(search({ query: q, hasImage: true, limit: 50 }));
|
||||
const videos = filterToServer(search({ query: q, hasVideo: true, limit: 50 }));
|
||||
const mediaMap = new Map();
|
||||
for (const r of [...images, ...videos]) mediaMap.set(r.id, r);
|
||||
const media = Array.from(mediaMap.values());
|
||||
media.sort((a, b) => b.created_at - a.created_at);
|
||||
setMediaResults(media);
|
||||
|
||||
// Links search
|
||||
const lnks = filterToServer(search({ query: q, hasLink: true, limit: 50 }));
|
||||
lnks.sort((a, b) => b.created_at - a.created_at);
|
||||
setLinkResults(lnks);
|
||||
|
||||
// Files search
|
||||
const files = filterToServer(search({ query: q, hasFile: true, limit: 50 }));
|
||||
files.sort((a, b) => b.created_at - a.created_at);
|
||||
setFileResults(files);
|
||||
|
||||
setSearching(false);
|
||||
}, 300);
|
||||
|
||||
return () => { if (searchTimerRef.current) clearTimeout(searchTimerRef.current); };
|
||||
}, [searchText, hasQuery, search, isReady, serverChannelIds]);
|
||||
|
||||
// Reset to first search tab when entering search mode
|
||||
useEffect(() => {
|
||||
if (hasQuery) {
|
||||
setActiveTab('Messages');
|
||||
} else {
|
||||
setActiveTab('Recent');
|
||||
}
|
||||
}, [hasQuery]);
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
setTimeout(onClose, 250);
|
||||
};
|
||||
|
||||
const handleSelectChannel = (channelId) => {
|
||||
setVisible(false);
|
||||
setTimeout(() => {
|
||||
onSelectChannel(channelId);
|
||||
onClose();
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const handleResultClick = useCallback((result) => {
|
||||
if (onJumpToMessage) {
|
||||
setVisible(false);
|
||||
setTimeout(() => {
|
||||
onJumpToMessage(result.channel_id, result.id);
|
||||
}, 250);
|
||||
}
|
||||
}, [onJumpToMessage]);
|
||||
|
||||
const query = searchText.toLowerCase().trim();
|
||||
|
||||
// Browse mode data
|
||||
const recentChannels = useMemo(() => {
|
||||
const textChannels = channels.filter(c => c.type === 'text');
|
||||
return textChannels
|
||||
.map(c => ({ ...c, lastActivity: latestTimestamps[c._id] || 0 }))
|
||||
.sort((a, b) => b.lastActivity - a.lastActivity)
|
||||
.filter(c => !query || c.name.toLowerCase().includes(query));
|
||||
}, [channels, latestTimestamps, query]);
|
||||
|
||||
const filteredMembers = useMemo(() => {
|
||||
if (!query) return allMembers;
|
||||
return allMembers.filter(m =>
|
||||
m.username.toLowerCase().includes(query) ||
|
||||
(m.displayName && m.displayName.toLowerCase().includes(query))
|
||||
);
|
||||
}, [allMembers, query]);
|
||||
|
||||
const filteredChannels = useMemo(() => {
|
||||
if (!query) return channels;
|
||||
return channels.filter(c => c.name.toLowerCase().includes(query));
|
||||
}, [channels, query]);
|
||||
|
||||
// Group results by channel
|
||||
const groupByChannel = useCallback((results) => {
|
||||
const grouped = {};
|
||||
for (const r of results) {
|
||||
const chName = channelMap[r.channel_id] || 'Unknown';
|
||||
if (!grouped[chName]) grouped[chName] = [];
|
||||
grouped[chName].push(r);
|
||||
}
|
||||
return grouped;
|
||||
}, [channelMap]);
|
||||
|
||||
const renderSearchResult = useCallback((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="mobile-search-result-item"
|
||||
onClick={() => handleResultClick(r)}
|
||||
>
|
||||
<div
|
||||
className="mobile-search-result-avatar"
|
||||
style={{ backgroundColor: getAvatarColor(r.username) }}
|
||||
>
|
||||
{r.username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div className="mobile-search-result-body">
|
||||
<div className="mobile-search-result-header">
|
||||
<span className="mobile-search-result-username" style={{ color: getAvatarColor(r.username) }}>
|
||||
{r.username}
|
||||
</span>
|
||||
<span className="mobile-search-result-time">{formatTime(r.created_at)}</span>
|
||||
</div>
|
||||
{!(r.has_attachment && r.attachment_meta) && (
|
||||
<div
|
||||
className="mobile-search-result-content"
|
||||
dangerouslySetInnerHTML={{ __html: formatEmojisHtml(linkifyHtml(r.snippet || escapeHtml(r.content)), customEmojis) }}
|
||||
onClick={(e) => {
|
||||
if (e.target.tagName === 'A' && e.target.href) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
links.openExternal(e.target.href);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{r.has_attachment && r.attachment_meta ? (() => {
|
||||
try {
|
||||
const meta = JSON.parse(r.attachment_meta);
|
||||
if (r.attachment_type?.startsWith('image/')) return <SearchResultImage metadata={meta} />;
|
||||
if (r.attachment_type?.startsWith('video/')) return <SearchResultVideo metadata={meta} />;
|
||||
return <SearchResultFile metadata={meta} />;
|
||||
} catch { return <span className="search-result-badge">File</span>; }
|
||||
})() : r.has_attachment ? <span className="search-result-badge">File</span> : null}
|
||||
{r.has_link && r.content && (() => {
|
||||
const urls = extractUrls(r.content);
|
||||
return urls.map((url, i) => <LinkPreview key={i} url={url} />);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
), [handleResultClick, customEmojis, links]);
|
||||
|
||||
const renderGroupedResults = useCallback((results) => {
|
||||
if (searching) {
|
||||
return <div className="mobile-search-empty">Searching...</div>;
|
||||
}
|
||||
if (!isReady) {
|
||||
return <div className="mobile-search-empty">Search database is loading...</div>;
|
||||
}
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className="mobile-search-empty">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor" style={{ opacity: 0.3, marginBottom: 8 }}>
|
||||
<path d="M21.71 20.29L18 16.61A9 9 0 1016.61 18l3.68 3.68a1 1 0 001.42 0 1 1 0 000-1.39zM11 18a7 7 0 110-14 7 7 0 010 14z"/>
|
||||
</svg>
|
||||
<div>No results found</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const grouped = groupByChannel(results);
|
||||
return (
|
||||
<div className="mobile-search-results">
|
||||
{Object.entries(grouped).map(([chName, msgs]) => (
|
||||
<div key={chName} className="mobile-search-channel-group">
|
||||
<div className="mobile-search-channel-group-header">#{chName}</div>
|
||||
{msgs.map(renderSearchResult)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}, [searching, isReady, groupByChannel, renderSearchResult]);
|
||||
|
||||
const renderContent = () => {
|
||||
// Search mode
|
||||
if (hasQuery) {
|
||||
switch (activeTab) {
|
||||
case 'Messages': return renderGroupedResults(messageResults);
|
||||
case 'Media': return renderGroupedResults(mediaResults);
|
||||
case 'Links': return renderGroupedResults(linkResults);
|
||||
case 'Files': return renderGroupedResults(fileResults);
|
||||
default: return renderGroupedResults(messageResults);
|
||||
}
|
||||
}
|
||||
|
||||
// Browse mode
|
||||
switch (activeTab) {
|
||||
case 'Recent':
|
||||
return (
|
||||
<div className="mobile-search-section">
|
||||
<div className="mobile-search-section-title">Suggested</div>
|
||||
{recentChannels.length === 0 ? (
|
||||
<div className="mobile-search-empty">No channels found</div>
|
||||
) : (
|
||||
recentChannels.map(channel => (
|
||||
<button
|
||||
key={channel._id}
|
||||
className="mobile-search-channel-item"
|
||||
onClick={() => handleSelectChannel(channel._id)}
|
||||
>
|
||||
<span className="mobile-search-channel-hash">#</span>
|
||||
<div className="mobile-search-channel-info">
|
||||
<span className="mobile-search-channel-name">{channel.name}</span>
|
||||
<span className="mobile-search-channel-activity">
|
||||
{formatTimeAgo(channel.lastActivity)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'Members':
|
||||
return (
|
||||
<div className="mobile-search-section">
|
||||
{filteredMembers.length === 0 ? (
|
||||
<div className="mobile-search-empty">No members found</div>
|
||||
) : (
|
||||
filteredMembers.map(member => {
|
||||
const effectiveStatus = resolveStatus(member.status, member.id);
|
||||
return (
|
||||
<div key={member.id} className="mobile-search-member-item">
|
||||
<div className="member-avatar-wrapper">
|
||||
{member.avatarUrl ? (
|
||||
<img
|
||||
className="member-avatar"
|
||||
src={member.avatarUrl}
|
||||
alt={member.username}
|
||||
style={{ objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="member-avatar"
|
||||
style={{ backgroundColor: getUserColor(member.username) }}
|
||||
>
|
||||
{(member.displayName || member.username).substring(0, 1).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="member-status-dot"
|
||||
style={{ backgroundColor: STATUS_COLORS[effectiveStatus] || STATUS_COLORS.offline }}
|
||||
/>
|
||||
</div>
|
||||
<span className="mobile-search-member-name">
|
||||
{member.displayName || member.username}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'Channels':
|
||||
return (
|
||||
<div className="mobile-search-section">
|
||||
{filteredChannels.length === 0 ? (
|
||||
<div className="mobile-search-empty">No channels found</div>
|
||||
) : (
|
||||
filteredChannels.map(channel => (
|
||||
<button
|
||||
key={channel._id}
|
||||
className="mobile-search-channel-item"
|
||||
onClick={() => handleSelectChannel(channel._id)}
|
||||
>
|
||||
<span className="mobile-search-channel-hash">
|
||||
{channel.type === 'voice' ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 3a1 1 0 0 0-1-1h-.06a1 1 0 0 0-.74.32L5.92 7H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.92l4.28 4.68a1 1 0 0 0 .74.32H11a1 1 0 0 0 1-1z" />
|
||||
</svg>
|
||||
) : '#'}
|
||||
</span>
|
||||
<span className="mobile-search-channel-name">{channel.name}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="mobile-search-empty">
|
||||
{activeTab} coming soon
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const currentTabs = hasQuery ? SEARCH_TABS : BROWSE_TABS;
|
||||
const getTabLabel = (tab) => {
|
||||
if (!hasQuery) return tab;
|
||||
switch (tab) {
|
||||
case 'Messages': return `Messages${!searching ? ` (${messageResults.length})` : ''}`;
|
||||
case 'Media': return `Media${!searching ? ` (${mediaResults.length})` : ''}`;
|
||||
case 'Links': return `Links${!searching ? ` (${linkResults.length})` : ''}`;
|
||||
case 'Files': return `Files${!searching ? ` (${fileResults.length})` : ''}`;
|
||||
default: return tab;
|
||||
}
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className={`mobile-search-screen${visible ? ' visible' : ''}`}>
|
||||
<div className="mobile-search-header">
|
||||
<button className="mobile-search-back" onClick={handleClose}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="mobile-search-input-wrapper">
|
||||
<svg className="mobile-search-input-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
|
||||
</svg>
|
||||
<input
|
||||
className="mobile-search-input"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{searchText && (
|
||||
<button className="mobile-search-clear" onClick={() => setSearchText('')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mobile-search-tabs">
|
||||
{currentTabs.map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
className={`mobile-search-tab${activeTab === tab ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
>
|
||||
{getTabLabel(tab)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mobile-search-content">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileSearchScreen;
|
||||
@@ -1,124 +0,0 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import ColoredIcon from './ColoredIcon';
|
||||
import inviteUserIcon from '../assets/icons/invite_user.svg';
|
||||
import settingsIcon from '../assets/icons/settings.svg';
|
||||
import createIcon from '../assets/icons/create.svg';
|
||||
import createCategoryIcon from '../assets/icons/create_category.svg';
|
||||
|
||||
const ICON_COLOR_DEFAULT = 'hsl(240, 4.294%, 68.039%)';
|
||||
|
||||
const MobileServerDrawer = ({ serverName, serverIconUrl, memberCount, onInvite, onSettings, onCreateChannel, onCreateCategory, onClose }) => {
|
||||
const [closing, setClosing] = useState(false);
|
||||
const drawerRef = useRef(null);
|
||||
const dragStartY = useRef(null);
|
||||
const dragCurrentY = useRef(null);
|
||||
const dragStartTime = useRef(null);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setClosing(true);
|
||||
setTimeout(onClose, 200);
|
||||
}, [onClose]);
|
||||
|
||||
const handleAction = useCallback((cb) => {
|
||||
dismiss();
|
||||
setTimeout(cb, 220);
|
||||
}, [dismiss]);
|
||||
|
||||
// Swipe-to-dismiss
|
||||
const handleTouchStart = useCallback((e) => {
|
||||
dragStartY.current = e.touches[0].clientY;
|
||||
dragCurrentY.current = e.touches[0].clientY;
|
||||
dragStartTime.current = Date.now();
|
||||
if (drawerRef.current) {
|
||||
drawerRef.current.style.transition = 'none';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback((e) => {
|
||||
if (dragStartY.current === null) return;
|
||||
dragCurrentY.current = e.touches[0].clientY;
|
||||
const dy = dragCurrentY.current - dragStartY.current;
|
||||
if (dy > 0 && drawerRef.current) {
|
||||
drawerRef.current.style.transform = `translateY(${dy}px)`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (dragStartY.current === null || !drawerRef.current) return;
|
||||
const dy = dragCurrentY.current - dragStartY.current;
|
||||
const dt = (Date.now() - dragStartTime.current) / 1000;
|
||||
const velocity = dt > 0 ? dy / dt : 0;
|
||||
const drawerHeight = drawerRef.current.offsetHeight;
|
||||
const threshold = drawerHeight * 0.3;
|
||||
|
||||
if (dy > threshold || velocity > 500) {
|
||||
dismiss();
|
||||
} else {
|
||||
drawerRef.current.style.transition = 'transform 0.2s ease-out';
|
||||
drawerRef.current.style.transform = 'translateY(0)';
|
||||
}
|
||||
dragStartY.current = null;
|
||||
}, [dismiss]);
|
||||
|
||||
const initials = serverName ? serverName.split(/\s+/).map(w => w[0]).join('').slice(0, 2).toUpperCase() : '?';
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<>
|
||||
<div className="mobile-drawer-overlay" onClick={dismiss} />
|
||||
<div
|
||||
ref={drawerRef}
|
||||
className={`mobile-drawer${closing ? ' mobile-drawer-closing' : ''}`}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<div className="mobile-drawer-handle">
|
||||
<div className="mobile-drawer-handle-bar" />
|
||||
</div>
|
||||
|
||||
{/* Server header */}
|
||||
<div className="mobile-server-drawer-header">
|
||||
<div className="mobile-server-drawer-icon">
|
||||
{serverIconUrl ? (
|
||||
<img src={serverIconUrl} alt={serverName} />
|
||||
) : (
|
||||
<span>{initials}</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mobile-server-drawer-name">{serverName}</div>
|
||||
<div className="mobile-server-drawer-members">{memberCount} {memberCount === 1 ? 'member' : 'members'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions card 1 */}
|
||||
<div className="mobile-drawer-card">
|
||||
<button className="mobile-drawer-action" onClick={() => handleAction(onInvite)}>
|
||||
<ColoredIcon src={inviteUserIcon} color={ICON_COLOR_DEFAULT} size="20px" />
|
||||
<span>Invite People</span>
|
||||
</button>
|
||||
<button className="mobile-drawer-action" onClick={() => handleAction(onSettings)}>
|
||||
<ColoredIcon src={settingsIcon} color={ICON_COLOR_DEFAULT} size="20px" />
|
||||
<span>Server Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Actions card 2 */}
|
||||
<div className="mobile-drawer-card">
|
||||
<button className="mobile-drawer-action" onClick={() => handleAction(onCreateChannel)}>
|
||||
<ColoredIcon src={createIcon} color={ICON_COLOR_DEFAULT} size="20px" />
|
||||
<span>Create Channel</span>
|
||||
</button>
|
||||
<button className="mobile-drawer-action" onClick={() => handleAction(onCreateCategory)}>
|
||||
<ColoredIcon src={createCategoryIcon} color={ICON_COLOR_DEFAULT} size="20px" />
|
||||
<span>Create Category</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileServerDrawer;
|
||||
@@ -1,199 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import MessageItem from './MessageItem';
|
||||
import { usePlatform } from '../platform';
|
||||
|
||||
const TAG_LENGTH = 32;
|
||||
const EMPTY = [];
|
||||
|
||||
const PinnedMessagesPanel = ({
|
||||
channelId,
|
||||
visible,
|
||||
onClose,
|
||||
channelKey,
|
||||
onJumpToMessage,
|
||||
userId,
|
||||
username,
|
||||
roles,
|
||||
Attachment,
|
||||
LinkPreview,
|
||||
DirectVideo,
|
||||
onReactionClick,
|
||||
onProfilePopup,
|
||||
onImageClick,
|
||||
}) => {
|
||||
const { crypto } = usePlatform();
|
||||
const [decryptedPins, setDecryptedPins] = useState([]);
|
||||
|
||||
const pinnedMessages = useQuery(
|
||||
api.messages.listPinned,
|
||||
channelId ? { channelId, userId: userId || undefined } : "skip"
|
||||
) || EMPTY;
|
||||
|
||||
const unpinMutation = useMutation(api.messages.pin);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pinnedMessages.length || !channelKey) {
|
||||
setDecryptedPins(prev => prev.length === 0 ? prev : []);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const decryptAll = async () => {
|
||||
// Build batch arrays for message decryption
|
||||
const decryptItems = [];
|
||||
const decryptMsgMap = [];
|
||||
const replyDecryptItems = [];
|
||||
const replyMsgMap = [];
|
||||
const verifyItems = [];
|
||||
const verifyMsgMap = [];
|
||||
|
||||
for (const msg of pinnedMessages) {
|
||||
if (msg.ciphertext && msg.ciphertext.length >= TAG_LENGTH) {
|
||||
const tag = msg.ciphertext.slice(-TAG_LENGTH);
|
||||
const content = msg.ciphertext.slice(0, -TAG_LENGTH);
|
||||
decryptItems.push({ ciphertext: content, key: channelKey, iv: msg.nonce, tag });
|
||||
decryptMsgMap.push(msg);
|
||||
}
|
||||
|
||||
if (msg.replyToContent && msg.replyToNonce) {
|
||||
const rTag = msg.replyToContent.slice(-TAG_LENGTH);
|
||||
const rContent = msg.replyToContent.slice(0, -TAG_LENGTH);
|
||||
replyDecryptItems.push({ ciphertext: rContent, key: channelKey, iv: msg.replyToNonce, tag: rTag });
|
||||
replyMsgMap.push(msg);
|
||||
}
|
||||
|
||||
if (msg.signature && msg.public_signing_key) {
|
||||
verifyItems.push({ publicKey: msg.public_signing_key, message: msg.ciphertext, signature: msg.signature });
|
||||
verifyMsgMap.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
const [decryptResults, replyResults, verifyResults] = await Promise.all([
|
||||
decryptItems.length > 0 ? crypto.decryptBatch(decryptItems) : [],
|
||||
replyDecryptItems.length > 0 ? crypto.decryptBatch(replyDecryptItems) : [],
|
||||
verifyItems.length > 0 ? crypto.verifyBatch(verifyItems) : [],
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const decryptedMap = new Map();
|
||||
for (let i = 0; i < decryptResults.length; i++) {
|
||||
const msg = decryptMsgMap[i];
|
||||
const result = decryptResults[i];
|
||||
decryptedMap.set(msg.id, result.success ? result.data : '[Decryption Error]');
|
||||
}
|
||||
|
||||
const replyMap = new Map();
|
||||
for (let i = 0; i < replyResults.length; i++) {
|
||||
const msg = replyMsgMap[i];
|
||||
const result = replyResults[i];
|
||||
if (result.success) {
|
||||
let text = result.data;
|
||||
if (text.startsWith('{')) text = '[Attachment]';
|
||||
else if (text.length > 100) text = text.substring(0, 100) + '...';
|
||||
replyMap.set(msg.id, text);
|
||||
} else {
|
||||
replyMap.set(msg.id, '[Encrypted]');
|
||||
}
|
||||
}
|
||||
|
||||
const verifyMap = new Map();
|
||||
for (let i = 0; i < verifyResults.length; i++) {
|
||||
const msg = verifyMsgMap[i];
|
||||
verifyMap.set(msg.id, verifyResults[i].success && verifyResults[i].verified);
|
||||
}
|
||||
|
||||
const results = pinnedMessages.map(msg => ({
|
||||
...msg,
|
||||
content: decryptedMap.get(msg.id) ?? '[Encrypted Message]',
|
||||
isVerified: verifyMap.get(msg.id) ?? false,
|
||||
decryptedReply: replyMap.get(msg.id) ?? null,
|
||||
}));
|
||||
|
||||
if (!cancelled) setDecryptedPins(results);
|
||||
};
|
||||
|
||||
decryptAll();
|
||||
return () => { cancelled = true; };
|
||||
}, [pinnedMessages, channelKey]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
return (
|
||||
<div className="pinned-panel">
|
||||
<div className="pinned-panel-header">
|
||||
<h3>Pinned Messages</h3>
|
||||
<button className="pinned-panel-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="pinned-panel-content">
|
||||
{decryptedPins.length === 0 ? (
|
||||
<div className="pinned-panel-empty">
|
||||
No pinned messages in this channel yet.
|
||||
</div>
|
||||
) : (
|
||||
decryptedPins.map(msg => {
|
||||
const isOwner = msg.username === username;
|
||||
return (
|
||||
<div key={msg.id} className="pinned-message-card">
|
||||
<MessageItem
|
||||
msg={msg}
|
||||
isGrouped={false}
|
||||
showDateDivider={false}
|
||||
showUnreadDivider={false}
|
||||
dateLabel=""
|
||||
isMentioned={false}
|
||||
isOwner={isOwner}
|
||||
isEditing={false}
|
||||
isHovered={false}
|
||||
editInput=""
|
||||
username={username}
|
||||
roles={roles}
|
||||
onHover={noop}
|
||||
onLeave={noop}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onAddReaction={noop}
|
||||
onEdit={noop}
|
||||
onReply={noop}
|
||||
onMore={noop}
|
||||
onEditInputChange={noop}
|
||||
onEditKeyDown={noop}
|
||||
onEditSave={noop}
|
||||
onEditCancel={noop}
|
||||
onReactionClick={onReactionClick}
|
||||
onScrollToMessage={onJumpToMessage}
|
||||
onProfilePopup={onProfilePopup}
|
||||
onImageClick={onImageClick}
|
||||
scrollToBottom={noop}
|
||||
Attachment={Attachment}
|
||||
LinkPreview={LinkPreview}
|
||||
DirectVideo={DirectVideo}
|
||||
/>
|
||||
<div className="pinned-message-actions">
|
||||
<button
|
||||
className="pinned-action-btn"
|
||||
onClick={() => onJumpToMessage && onJumpToMessage(msg.id)}
|
||||
>
|
||||
Jump
|
||||
</button>
|
||||
<button
|
||||
className="pinned-action-btn pinned-action-danger"
|
||||
onClick={() => unpinMutation({ id: msg.id, pinned: false })}
|
||||
>
|
||||
Unpin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PinnedMessagesPanel;
|
||||
@@ -1,283 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { usePlatform } from '../platform';
|
||||
|
||||
const ScreenShareModal = ({ onClose, onSelectSource }) => {
|
||||
const { screenCapture } = usePlatform();
|
||||
const [activeTab, setActiveTab] = useState('applications'); // applications | screens | devices
|
||||
const [sources, setSources] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [shareAudio, setShareAudio] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadSources();
|
||||
}, []);
|
||||
|
||||
const [isWebFallback, setIsWebFallback] = useState(false);
|
||||
|
||||
const loadSources = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Get screen/window sources from Electron
|
||||
const desktopSources = await screenCapture.getScreenSources();
|
||||
|
||||
// If no desktop sources (web platform), use getDisplayMedia fallback
|
||||
if (!desktopSources || desktopSources.length === 0) {
|
||||
setIsWebFallback(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get video input devices (webcams)
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const videoDevices = devices.filter(d => d.kind === 'videoinput');
|
||||
|
||||
// Categorize
|
||||
const apps = desktopSources.filter(s => s.id.startsWith('window'));
|
||||
const screens = desktopSources.filter(s => s.id.startsWith('screen'));
|
||||
|
||||
const formattedDevices = videoDevices.map(d => ({
|
||||
id: d.deviceId,
|
||||
name: d.label || `Camera ${d.deviceId.substring(0, 4)}...`,
|
||||
isDevice: true,
|
||||
thumbnail: null // Devices don't have static thumbnails easily referencable without opening stream
|
||||
}));
|
||||
|
||||
setSources({
|
||||
applications: apps,
|
||||
screens: screens,
|
||||
devices: formattedDevices
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to load sources:", err);
|
||||
setIsWebFallback(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWebFallback = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: { frameRate: { ideal: 60, max: 60 } },
|
||||
audio: shareAudio,
|
||||
});
|
||||
onSelectSource({ type: 'web_stream', stream, shareAudio });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
if (err.name !== 'NotAllowedError') {
|
||||
console.error('getDisplayMedia failed:', err);
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-trigger the browser picker on web
|
||||
useEffect(() => {
|
||||
if (isWebFallback) {
|
||||
handleWebFallback();
|
||||
}
|
||||
}, [isWebFallback]);
|
||||
|
||||
const handleSelect = (source) => {
|
||||
// If device, pass constraints differently (webcams don't have loopback audio)
|
||||
if (source.isDevice) {
|
||||
onSelectSource({ deviceId: source.id, type: 'device', shareAudio: false });
|
||||
} else {
|
||||
onSelectSource({ sourceId: source.id, type: 'screen', shareAudio });
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const renderGrid = (items) => {
|
||||
if (!items || items.length === 0) return <div style={{ color: '#b9bbbe', padding: 20, textAlign: 'center' }}>No sources found.</div>;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '16px', padding: '16px' }}>
|
||||
{items.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleSelect(item)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: '4px',
|
||||
padding: '8px',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative', width: '100%', height: '250px', width: '450px', borderRadius: '8px', overflow: 'hidden', marginBottom: '8px' }}>
|
||||
{/* Thumbnail/Placeholder */}
|
||||
{item.thumbnail ? (
|
||||
<img src={item.thumbnail} alt={item.name} style={{ width: '100%', height: '100%', borderRadius: '8px', objectFit: 'cover', display: 'block' }} />
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', backgroundColor: '#202225', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#b9bbbe' }}>
|
||||
📷
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover Overlay */}
|
||||
<div
|
||||
className="share-overlay"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.opacity = 1}
|
||||
onMouseLeave={(e) => e.currentTarget.style.opacity = 0}
|
||||
>
|
||||
<button style={{
|
||||
backgroundColor: '#5865F2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '4px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.3)'
|
||||
}}>
|
||||
Share Screen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginTop: '4px', maxWidth: '450px' }}>
|
||||
{item.appIcon && (
|
||||
<img src={item.appIcon} alt="" style={{ width: '20px', height: '20px', marginRight: '8px' }} />
|
||||
)}
|
||||
<div style={{ color: '#dcddde', fontSize: '14px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: '500' }}>
|
||||
{item.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
zIndex: 10000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}} onClick={onClose}>
|
||||
<style>{`
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
`}</style>
|
||||
<div style={{
|
||||
backgroundColor: '#242429',
|
||||
borderRadius: '8px',
|
||||
width: '965px',
|
||||
height: '740px',
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '95vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 8px 16px rgba(0,0,0,0.24)'
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
|
||||
{/* Header/Tabs */}
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid #2f3136', padding: '16px 16px 0 16px', flexShrink: 0 }}>
|
||||
<div
|
||||
onClick={() => setActiveTab('applications')}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
borderBottom: activeTab === 'applications' ? '2px solid white' : '2px solid transparent',
|
||||
color: activeTab === 'applications' ? 'white' : '#b9bbbe',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Applications
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setActiveTab('screens')}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
borderBottom: activeTab === 'screens' ? '2px solid white' : '2px solid transparent',
|
||||
color: activeTab === 'screens' ? 'white' : '#b9bbbe',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Screens
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setActiveTab('devices')}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
borderBottom: activeTab === 'devices' ? '2px solid white' : '2px solid transparent',
|
||||
color: activeTab === 'devices' ? 'white' : '#b9bbbe',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Devices
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="no-scrollbar" style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: '#b9bbbe' }}>
|
||||
Loading sources...
|
||||
</div>
|
||||
) : (
|
||||
renderGrid(sources[activeTab])
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Audio sharing footer — hidden for device sources (webcams) */}
|
||||
{activeTab !== 'devices' && (
|
||||
<div style={{
|
||||
borderTop: '1px solid #2f3136',
|
||||
padding: '12px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<label style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
cursor: 'pointer',
|
||||
color: '#dcddde',
|
||||
fontSize: '14px',
|
||||
userSelect: 'none',
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shareAudio}
|
||||
onChange={(e) => setShareAudio(e.target.checked)}
|
||||
style={{ accentColor: '#5865F2', width: '16px', height: '16px', cursor: 'pointer' }}
|
||||
/>
|
||||
Also share computer audio
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScreenShareModal;
|
||||
@@ -1,244 +0,0 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { detectActivePrefix } from '../utils/searchUtils';
|
||||
|
||||
const FILTER_SUGGESTIONS = [
|
||||
{ prefix: 'from:', label: 'from:', description: 'user', icon: 'user' },
|
||||
{ prefix: 'mentions:', label: 'mentions:', description: 'user', icon: 'at' },
|
||||
{ prefix: 'has:', label: 'has:', description: 'link, file, image, or video', icon: 'has' },
|
||||
{ prefix: 'in:', label: 'in:', description: 'channel', icon: 'channel' },
|
||||
{ prefix: 'before:', label: 'before:', description: 'date', icon: 'date' },
|
||||
{ prefix: 'after:', label: 'after:', description: 'date', icon: 'date' },
|
||||
{ prefix: 'pinned:', label: 'pinned:', description: 'true or false', icon: 'pin' },
|
||||
];
|
||||
|
||||
const HAS_OPTIONS = [
|
||||
{ value: 'link', label: 'link' },
|
||||
{ value: 'file', label: 'file' },
|
||||
{ value: 'image', label: 'image' },
|
||||
{ value: 'video', label: 'video' },
|
||||
];
|
||||
|
||||
function getAvatarColor(name) {
|
||||
const colors = ['#5865F2', '#57F287', '#FEE75C', '#EB459E', '#ED4245', '#F47B67', '#E67E22', '#3498DB'];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < (name || '').length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
function FilterIcon({ type }) {
|
||||
switch (type) {
|
||||
case 'user':
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||
</svg>
|
||||
);
|
||||
case 'at':
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10h5v-2h-5c-4.34 0-8-3.66-8-8s3.66-8 8-8 8 3.66 8 8v1.43c0 .79-.71 1.57-1.5 1.57s-1.5-.78-1.5-1.57V12c0-2.76-2.24-5-5-5s-5 2.24-5 5 2.24 5 5 5c1.38 0 2.64-.56 3.54-1.47.65.89 1.77 1.47 2.96 1.47 1.97 0 3.5-1.6 3.5-3.57V12c0-5.52-4.48-10-10-10zm0 13c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3z"/>
|
||||
</svg>
|
||||
);
|
||||
case 'has':
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/>
|
||||
</svg>
|
||||
);
|
||||
case 'channel':
|
||||
return <span style={{ fontSize: 16, fontWeight: 700, opacity: 0.7 }}>#</span>;
|
||||
case 'date':
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11z"/>
|
||||
</svg>
|
||||
);
|
||||
case 'pin':
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.3 5.3a1 1 0 00-1.4-1.4L14.6 7.2l-1.5-.8a2 2 0 00-2.2.2L8.5 9a1 1 0 000 1.5l1.8 1.8-4.6 4.6a1 1 0 001.4 1.4l4.6-4.6 1.8 1.8a1 1 0 001.5 0l2.4-2.4a2 2 0 00.2-2.2l-.8-1.5 3.3-3.3z"/>
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const SearchDropdown = ({
|
||||
visible,
|
||||
searchText,
|
||||
channels,
|
||||
members,
|
||||
searchHistory,
|
||||
onSelectFilter,
|
||||
onSelectHistoryItem,
|
||||
onClearHistory,
|
||||
onClearHistoryItem,
|
||||
anchorRef,
|
||||
onClose,
|
||||
}) => {
|
||||
const dropdownRef = useRef(null);
|
||||
const [pos, setPos] = useState({ top: 0, left: 0, width: 420 });
|
||||
|
||||
// Position dropdown below anchor
|
||||
useEffect(() => {
|
||||
if (!visible || !anchorRef?.current) return;
|
||||
const rect = anchorRef.current.getBoundingClientRect();
|
||||
setPos({
|
||||
top: rect.bottom + 4,
|
||||
left: Math.max(rect.right - 420, 8),
|
||||
width: 420,
|
||||
});
|
||||
}, [visible, anchorRef, searchText]);
|
||||
|
||||
// Click outside to close
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const handler = (e) => {
|
||||
if (
|
||||
dropdownRef.current && !dropdownRef.current.contains(e.target) &&
|
||||
anchorRef?.current && !anchorRef.current.contains(e.target)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [visible, onClose, anchorRef]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const activePrefix = detectActivePrefix(searchText);
|
||||
|
||||
let content;
|
||||
|
||||
if (activePrefix?.prefix === 'from' || activePrefix?.prefix === 'mentions') {
|
||||
const filtered = (members || []).filter(m =>
|
||||
m.username.toLowerCase().includes(activePrefix.partial)
|
||||
);
|
||||
const headerText = activePrefix.prefix === 'from' ? 'FROM USER' : 'MENTIONS USER';
|
||||
content = (
|
||||
<div className="search-dropdown-scrollable">
|
||||
<div className="search-dropdown-section-header">{headerText}</div>
|
||||
{filtered.length === 0 && (
|
||||
<div className="search-dropdown-empty">No matching users</div>
|
||||
)}
|
||||
{filtered.map(m => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="search-dropdown-member"
|
||||
onClick={() => onSelectFilter(activePrefix.prefix, m.username)}
|
||||
>
|
||||
{m.avatarUrl ? (
|
||||
<img src={m.avatarUrl} className="search-dropdown-avatar" alt="" />
|
||||
) : (
|
||||
<div className="search-dropdown-avatar" style={{ backgroundColor: getAvatarColor(m.username) }}>
|
||||
{m.username[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="search-dropdown-member-name">{m.username}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} else if (activePrefix?.prefix === 'in') {
|
||||
const filtered = (channels || []).filter(c =>
|
||||
c.name?.toLowerCase().includes(activePrefix.partial) && c.type === 'text'
|
||||
);
|
||||
content = (
|
||||
<div className="search-dropdown-scrollable">
|
||||
<div className="search-dropdown-section-header">IN CHANNEL</div>
|
||||
{filtered.length === 0 && (
|
||||
<div className="search-dropdown-empty">No matching channels</div>
|
||||
)}
|
||||
{filtered.map(c => (
|
||||
<div
|
||||
key={c._id}
|
||||
className="search-dropdown-channel"
|
||||
onClick={() => onSelectFilter('in', c.name)}
|
||||
>
|
||||
<span className="search-dropdown-channel-hash">#</span>
|
||||
<span>{c.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} else if (activePrefix?.prefix === 'has') {
|
||||
const filtered = HAS_OPTIONS.filter(o =>
|
||||
o.value.includes(activePrefix.partial)
|
||||
);
|
||||
content = (
|
||||
<div className="search-dropdown-scrollable">
|
||||
<div className="search-dropdown-section-header">MESSAGE CONTAINS</div>
|
||||
{filtered.map(o => (
|
||||
<div
|
||||
key={o.value}
|
||||
className="search-dropdown-item"
|
||||
onClick={() => onSelectFilter('has', o.value)}
|
||||
>
|
||||
<FilterIcon type="has" />
|
||||
<span>{o.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// Default: show filter suggestions + search history
|
||||
content = (
|
||||
<div className="search-dropdown-scrollable">
|
||||
<div className="search-dropdown-section-header">SEARCH OPTIONS</div>
|
||||
{FILTER_SUGGESTIONS.map(f => (
|
||||
<div
|
||||
key={f.prefix}
|
||||
className="search-dropdown-item"
|
||||
onClick={() => onSelectFilter(f.prefix.replace(':', ''), null)}
|
||||
>
|
||||
<span className="search-dropdown-item-icon"><FilterIcon type={f.icon} /></span>
|
||||
<span className="search-dropdown-item-label">{f.label}</span>
|
||||
<span className="search-dropdown-item-desc">{f.description}</span>
|
||||
</div>
|
||||
))}
|
||||
{searchHistory && searchHistory.length > 0 && (
|
||||
<>
|
||||
<div className="search-dropdown-section-header search-dropdown-history-header">
|
||||
<span>SEARCH HISTORY</span>
|
||||
<button className="search-dropdown-clear-all" onClick={onClearHistory}>Clear</button>
|
||||
</div>
|
||||
{searchHistory.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="search-dropdown-history-item"
|
||||
onClick={() => onSelectHistoryItem(item)}
|
||||
>
|
||||
<svg className="search-dropdown-history-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M13 3a9 9 0 00-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0013 21a9 9 0 000-18zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"/>
|
||||
</svg>
|
||||
<span className="search-dropdown-history-text">{item}</span>
|
||||
<button
|
||||
className="search-dropdown-history-delete"
|
||||
onClick={(e) => { e.stopPropagation(); onClearHistoryItem(i); }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="search-dropdown"
|
||||
style={{ top: pos.top, left: pos.left, width: pos.width }}
|
||||
>
|
||||
{content}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchDropdown;
|
||||
@@ -1,235 +0,0 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import { useSearch } from '../contexts/SearchContext';
|
||||
import { parseFilters } from '../utils/searchUtils';
|
||||
import { usePlatform } from '../platform';
|
||||
import { LinkPreview } from './ChatArea';
|
||||
import { extractUrls } from './MessageItem';
|
||||
import {
|
||||
formatTime, escapeHtml, linkifyHtml, formatEmojisHtml, getAvatarColor,
|
||||
SearchResultImage, SearchResultVideo, SearchResultFile
|
||||
} from '../utils/searchRendering';
|
||||
|
||||
const SearchPanel = ({ visible, onClose, channels, isDM, dmChannelId, onJumpToMessage, query, sortOrder, onSortChange }) => {
|
||||
const { search, isReady } = useSearch() || {};
|
||||
const { links } = usePlatform();
|
||||
const customEmojis = useQuery(api.customEmojis.list) || [];
|
||||
const [results, setResults] = useState([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [showSortMenu, setShowSortMenu] = useState(false);
|
||||
|
||||
// Execute search when query changes
|
||||
useEffect(() => {
|
||||
if (!visible || !query?.trim() || !search || !isReady) {
|
||||
if (!query?.trim()) setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
const { textQuery, filters } = parseFilters(query);
|
||||
|
||||
let channelId;
|
||||
if (isDM) {
|
||||
// In DM view — always scope to the DM channel
|
||||
channelId = dmChannelId;
|
||||
} else {
|
||||
channelId = filters.channelName
|
||||
? channels?.find(c => c.name?.toLowerCase() === filters.channelName.toLowerCase())?._id
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const params = {
|
||||
query: textQuery || undefined,
|
||||
channelId,
|
||||
senderName: filters.senderName,
|
||||
hasLink: filters.hasLink,
|
||||
hasImage: filters.hasImage,
|
||||
hasVideo: filters.hasVideo,
|
||||
hasFile: filters.hasFile,
|
||||
hasMention: filters.hasMention,
|
||||
before: filters.before,
|
||||
after: filters.after,
|
||||
pinned: filters.pinned,
|
||||
limit: 25,
|
||||
};
|
||||
|
||||
const res = search(params);
|
||||
|
||||
let filtered;
|
||||
if (isDM) {
|
||||
// In DM view — results are already scoped to dmChannelId
|
||||
filtered = res;
|
||||
} else {
|
||||
// In server view — filter out DM messages
|
||||
const serverChannelIds = new Set(channels?.map(c => c._id) || []);
|
||||
filtered = res.filter(r => serverChannelIds.has(r.channel_id));
|
||||
}
|
||||
|
||||
// Sort results
|
||||
let sorted = [...filtered];
|
||||
if (sortOrder === 'oldest') {
|
||||
sorted.sort((a, b) => a.created_at - b.created_at);
|
||||
} else {
|
||||
// newest first (default)
|
||||
sorted.sort((a, b) => b.created_at - a.created_at);
|
||||
}
|
||||
|
||||
setResults(sorted);
|
||||
setSearching(false);
|
||||
}, [visible, query, sortOrder, search, isReady, channels, isDM, dmChannelId]);
|
||||
|
||||
const handleResultClick = useCallback((result) => {
|
||||
onJumpToMessage(result.channel_id, result.id);
|
||||
}, [onJumpToMessage]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const channelMap = {};
|
||||
if (channels) {
|
||||
for (const c of channels) channelMap[c._id] = c.name;
|
||||
}
|
||||
|
||||
// Group results by channel
|
||||
const grouped = {};
|
||||
for (const r of results) {
|
||||
const chName = channelMap[r.channel_id] || 'Unknown';
|
||||
if (!grouped[chName]) grouped[chName] = [];
|
||||
grouped[chName].push(r);
|
||||
}
|
||||
|
||||
const { filters: activeFilters } = query?.trim() ? parseFilters(query) : { filters: {} };
|
||||
const filterChips = [];
|
||||
if (activeFilters.senderName) filterChips.push({ label: `from: ${activeFilters.senderName}`, key: 'from' });
|
||||
if (activeFilters.hasLink) filterChips.push({ label: 'has: link', key: 'hasLink' });
|
||||
if (activeFilters.hasImage) filterChips.push({ label: 'has: image', key: 'hasImage' });
|
||||
if (activeFilters.hasVideo) filterChips.push({ label: 'has: video', key: 'hasVideo' });
|
||||
if (activeFilters.hasFile) filterChips.push({ label: 'has: file', key: 'hasFile' });
|
||||
if (activeFilters.hasMention) filterChips.push({ label: 'has: mention', key: 'hasMention' });
|
||||
if (activeFilters.before) filterChips.push({ label: `before: ${activeFilters.before}`, key: 'before' });
|
||||
if (activeFilters.after) filterChips.push({ label: `after: ${activeFilters.after}`, key: 'after' });
|
||||
if (activeFilters.pinned) filterChips.push({ label: 'pinned: true', key: 'pinned' });
|
||||
if (activeFilters.channelName) filterChips.push({ label: `in: ${activeFilters.channelName}`, key: 'in' });
|
||||
|
||||
const sortLabel = sortOrder === 'oldest' ? 'Oldest' : 'Newest';
|
||||
|
||||
return (
|
||||
<div className="search-panel">
|
||||
<div className="search-panel-header">
|
||||
<div className="search-panel-header-left">
|
||||
<span className="search-result-count">
|
||||
{results.length} result{results.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="search-panel-header-right">
|
||||
<div className="search-panel-sort-wrapper">
|
||||
<button
|
||||
className="search-panel-sort-btn"
|
||||
onClick={() => setShowSortMenu(prev => !prev)}
|
||||
>
|
||||
{sortLabel}
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: 4 }}>
|
||||
<path d="M7 10l5 5 5-5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
{showSortMenu && (
|
||||
<div className="search-panel-sort-menu">
|
||||
<div
|
||||
className={`search-panel-sort-option ${sortOrder === 'newest' ? 'active' : ''}`}
|
||||
onClick={() => { onSortChange('newest'); setShowSortMenu(false); }}
|
||||
>
|
||||
Newest
|
||||
</div>
|
||||
<div
|
||||
className={`search-panel-sort-option ${sortOrder === 'oldest' ? 'active' : ''}`}
|
||||
onClick={() => { onSortChange('oldest'); setShowSortMenu(false); }}
|
||||
>
|
||||
Oldest
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="search-panel-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filterChips.length > 0 && (
|
||||
<div className="search-filter-chips">
|
||||
{filterChips.map(chip => (
|
||||
<span key={chip.key} className="search-filter-chip">
|
||||
{chip.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="search-panel-results">
|
||||
{!isReady && (
|
||||
<div className="search-panel-empty">Search database is loading...</div>
|
||||
)}
|
||||
{isReady && searching && <div className="search-panel-empty">Searching...</div>}
|
||||
{isReady && !searching && results.length === 0 && (
|
||||
<div className="search-panel-empty">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="currentColor" style={{ opacity: 0.3, marginBottom: 8 }}>
|
||||
<path d="M21.71 20.29L18 16.61A9 9 0 1016.61 18l3.68 3.68a1 1 0 001.42 0 1 1 0 000-1.39zM11 18a7 7 0 110-14 7 7 0 010 14z"/>
|
||||
</svg>
|
||||
<div>No results found</div>
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(grouped).map(([chName, msgs]) => (
|
||||
<div key={chName}>
|
||||
<div className="search-channel-header">{isDM ? chName : `#${chName}`}</div>
|
||||
{msgs.map(r => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="search-result"
|
||||
onClick={() => handleResultClick(r)}
|
||||
>
|
||||
<div
|
||||
className="search-result-avatar"
|
||||
style={{ backgroundColor: getAvatarColor(r.username) }}
|
||||
>
|
||||
{r.username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div className="search-result-body">
|
||||
<div className="search-result-header">
|
||||
<span className="search-result-username">{r.username}</span>
|
||||
<span className="search-result-time">{formatTime(r.created_at)}</span>
|
||||
</div>
|
||||
{!(r.has_attachment && r.attachment_meta) && (
|
||||
<div
|
||||
className="search-result-content"
|
||||
dangerouslySetInnerHTML={{ __html: formatEmojisHtml(linkifyHtml(r.snippet || escapeHtml(r.content)), customEmojis) }}
|
||||
onClick={(e) => {
|
||||
if (e.target.tagName === 'A' && e.target.href) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
links.openExternal(e.target.href);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{r.has_attachment && r.attachment_meta ? (() => {
|
||||
try {
|
||||
const meta = JSON.parse(r.attachment_meta);
|
||||
if (r.attachment_type?.startsWith('image/')) return <SearchResultImage metadata={meta} />;
|
||||
if (r.attachment_type?.startsWith('video/')) return <SearchResultVideo metadata={meta} />;
|
||||
return <SearchResultFile metadata={meta} />;
|
||||
} catch { return <span className="search-result-badge">File</span>; }
|
||||
})() : r.has_attachment ? <span className="search-result-badge">File</span> : null}
|
||||
{r.has_link && r.content && (() => {
|
||||
const urls = extractUrls(r.content);
|
||||
return urls.map((url, i) => <LinkPreview key={i} url={url} />);
|
||||
})()}
|
||||
{r.pinned && <span className="search-result-badge">Pinned</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchPanel;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,51 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
const SlashCommandMenu = ({ commands, selectedIndex, onSelect, onHover }) => {
|
||||
const scrollerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollerRef.current) return;
|
||||
const selected = scrollerRef.current.querySelector('.slash-command-row.selected');
|
||||
if (selected) selected.scrollIntoView({ block: 'nearest' });
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (!commands || commands.length === 0) return null;
|
||||
|
||||
const grouped = {};
|
||||
for (const cmd of commands) {
|
||||
const cat = cmd.category || 'Built-In';
|
||||
if (!grouped[cat]) grouped[cat] = [];
|
||||
grouped[cat].push(cmd);
|
||||
}
|
||||
|
||||
let globalIndex = 0;
|
||||
|
||||
return (
|
||||
<div className="slash-command-menu">
|
||||
<div className="slash-command-scroller" ref={scrollerRef}>
|
||||
{Object.entries(grouped).map(([category, cmds]) => (
|
||||
<React.Fragment key={category}>
|
||||
<div className="slash-command-section-header">{category}</div>
|
||||
{cmds.map((cmd) => {
|
||||
const idx = globalIndex++;
|
||||
return (
|
||||
<div
|
||||
key={cmd.name}
|
||||
className={`slash-command-row${idx === selectedIndex ? ' selected' : ''}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onSelect(cmd)}
|
||||
onMouseEnter={() => onHover(idx)}
|
||||
>
|
||||
<span className="slash-command-name">/{cmd.name}</span>
|
||||
<span className="slash-command-description">{cmd.description}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SlashCommandMenu;
|
||||
@@ -1,56 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTheme, THEMES, THEME_LABELS } from '../contexts/ThemeContext';
|
||||
|
||||
const THEME_PREVIEWS = {
|
||||
[THEMES.LIGHT]: { bg: '#ffffff', sidebar: '#f2f3f5', tertiary: '#e3e5e8', text: '#313338' },
|
||||
[THEMES.DARK]: { bg: '#313338', sidebar: '#2b2d31', tertiary: '#1e1f22', text: '#f2f3f5' },
|
||||
[THEMES.ASH]: { bg: '#202225', sidebar: '#1a1b1e', tertiary: '#111214', text: '#f0f1f3' },
|
||||
[THEMES.ONYX]: { bg: '#0c0c14', sidebar: '#080810', tertiary: '#000000', text: '#e0def0' },
|
||||
};
|
||||
|
||||
const ThemeSelector = ({ onClose }) => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="theme-selector-overlay" onClick={onClose}>
|
||||
<div className="theme-selector-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="theme-selector-header">
|
||||
<h2>Appearance</h2>
|
||||
<button className="theme-selector-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<div className="theme-selector-grid">
|
||||
{Object.values(THEMES).map((themeKey) => {
|
||||
const preview = THEME_PREVIEWS[themeKey];
|
||||
const isActive = theme === themeKey;
|
||||
return (
|
||||
<div
|
||||
key={themeKey}
|
||||
className={`theme-card ${isActive ? 'active' : ''}`}
|
||||
onClick={() => setTheme(themeKey)}
|
||||
>
|
||||
<div className="theme-preview" style={{ backgroundColor: preview.bg }}>
|
||||
<div className="theme-preview-sidebar" style={{ backgroundColor: preview.sidebar }}>
|
||||
<div className="theme-preview-channel" style={{ backgroundColor: preview.tertiary }} />
|
||||
<div className="theme-preview-channel" style={{ backgroundColor: preview.tertiary, width: '60%' }} />
|
||||
</div>
|
||||
<div className="theme-preview-chat">
|
||||
<div className="theme-preview-message" style={{ backgroundColor: preview.sidebar, opacity: 0.6 }} />
|
||||
<div className="theme-preview-message" style={{ backgroundColor: preview.sidebar, opacity: 0.4, width: '70%' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="theme-card-label">
|
||||
<div className={`theme-radio ${isActive ? 'active' : ''}`}>
|
||||
{isActive && <div className="theme-radio-dot" />}
|
||||
</div>
|
||||
<span>{THEME_LABELS[themeKey]}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeSelector;
|
||||
@@ -1,48 +0,0 @@
|
||||
import React from 'react';
|
||||
import { usePlatform } from '../platform';
|
||||
import { TitleBarUpdateIcon } from './UpdateBanner';
|
||||
|
||||
const TitleBar = () => {
|
||||
const { windowControls, features } = usePlatform();
|
||||
|
||||
if (!features.hasWindowControls) return null;
|
||||
|
||||
return (
|
||||
<div className="titlebar">
|
||||
<div className="titlebar-drag-region" />
|
||||
<div className="titlebar-title">Brycord</div>
|
||||
<div className="titlebar-buttons">
|
||||
<TitleBarUpdateIcon />
|
||||
<button
|
||||
className="titlebar-btn titlebar-minimize"
|
||||
onClick={() => windowControls.minimize()}
|
||||
aria-label="Minimize"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||
<rect fill="currentColor" width="10" height="1" x="1" y="6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="titlebar-btn titlebar-maximize"
|
||||
onClick={() => windowControls.maximize()}
|
||||
aria-label="Maximize"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||
<rect fill="none" stroke="currentColor" strokeWidth="1" width="8" height="8" x="2" y="2" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="titlebar-btn titlebar-close"
|
||||
onClick={() => windowControls.close()}
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||
<polygon fill="currentColor" points="11,1.576 10.424,1 6,5.424 1.576,1 1,1.576 5.424,6 1,10.424 1.576,11 6,6.576 10.424,11 11,10.424 6.576,6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TitleBar;
|
||||
@@ -1,69 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||
|
||||
function getUserColor(name) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
|
||||
}
|
||||
|
||||
const ToastContainer = ({ toasts, removeToast }) => {
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className="toast-container">
|
||||
{toasts.map(toast => (
|
||||
<ToastItem key={toast.id} toast={toast} onDismiss={() => removeToast(toast.id)} />
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
const ToastItem = ({ toast, onDismiss }) => {
|
||||
const [exiting, setExiting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setExiting(true);
|
||||
setTimeout(onDismiss, 300);
|
||||
}, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onDismiss]);
|
||||
|
||||
return (
|
||||
<div className={`toast ${exiting ? 'toast-exit' : 'toast-enter'}`}>
|
||||
<div className="toast-avatar" style={{ backgroundColor: getUserColor(toast.username || '') }}>
|
||||
{(toast.username || '?').substring(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div className="toast-body">
|
||||
<div className="toast-title">New message from <strong>{toast.username}</strong></div>
|
||||
<div className="toast-preview">{toast.preview}</div>
|
||||
</div>
|
||||
<button className="toast-close" onClick={() => { setExiting(true); setTimeout(onDismiss, 300); }}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function useToasts() {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
|
||||
const addToast = useCallback((toast) => {
|
||||
const id = Date.now() + Math.random();
|
||||
setToasts(prev => [...prev.slice(-4), { ...toast, id }]);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, []);
|
||||
|
||||
return { toasts, addToast, removeToast, ToastContainer: () => <ToastContainer toasts={toasts} removeToast={removeToast} /> };
|
||||
}
|
||||
|
||||
export default ToastContainer;
|
||||
@@ -1,90 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
const Tooltip = ({ children, text, position = 'top' }) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
||||
const triggerRef = useRef(null);
|
||||
const timeoutRef = useRef(null);
|
||||
|
||||
const showTooltip = () => {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
let top, left;
|
||||
|
||||
switch (position) {
|
||||
case 'bottom':
|
||||
top = rect.bottom + 8;
|
||||
left = rect.left + rect.width / 2;
|
||||
break;
|
||||
case 'left':
|
||||
top = rect.top + rect.height / 2;
|
||||
left = rect.left - 8;
|
||||
break;
|
||||
case 'right':
|
||||
top = rect.top + rect.height / 2;
|
||||
left = rect.right + 8;
|
||||
break;
|
||||
default: // top
|
||||
top = rect.top - 8;
|
||||
left = rect.left + rect.width / 2;
|
||||
break;
|
||||
}
|
||||
setCoords({ top, left });
|
||||
setVisible(true);
|
||||
}
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const hideTooltip = () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); };
|
||||
}, []);
|
||||
|
||||
const getTransformStyle = () => {
|
||||
switch (position) {
|
||||
case 'bottom': return 'translate(-50%, 0)';
|
||||
case 'left': return 'translate(-100%, -50%)';
|
||||
case 'right': return 'translate(0, -50%)';
|
||||
default: return 'translate(-50%, -100%)';
|
||||
}
|
||||
};
|
||||
|
||||
const getArrowClass = () => `tooltip-arrow tooltip-arrow-${position}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={triggerRef}
|
||||
onMouseEnter={showTooltip}
|
||||
onMouseLeave={hideTooltip}
|
||||
style={{ display: 'inline-flex' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{visible && ReactDOM.createPortal(
|
||||
<div
|
||||
className="tooltip"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: coords.top,
|
||||
left: coords.left,
|
||||
transform: getTransformStyle(),
|
||||
zIndex: 10001,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
<div className={getArrowClass()} />
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tooltip;
|
||||
@@ -1,143 +0,0 @@
|
||||
import React, { useState, useEffect, createContext, useContext } from 'react';
|
||||
import { usePlatform } from '../platform';
|
||||
import ColoredIcon from './ColoredIcon';
|
||||
import updateIcon from '../assets/icons/update.svg';
|
||||
|
||||
const RELEASE_URL = 'https://gitea.moyettes.com/Moyettes/DiscordClone/releases/tag/latest';
|
||||
|
||||
const UpdateContext = createContext(null);
|
||||
|
||||
export function useUpdateCheck() {
|
||||
return useContext(UpdateContext);
|
||||
}
|
||||
|
||||
export function UpdateProvider({ children }) {
|
||||
const { updates, features } = usePlatform();
|
||||
const [state, setState] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!features.hasNativeUpdates || !updates) return;
|
||||
|
||||
updates.checkUpdate().then((result) => {
|
||||
if (!result?.updateAvailable) return;
|
||||
setState(result);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UpdateContext.Provider value={state}>
|
||||
{children}
|
||||
{state && (state.updateType === 'major' || state.updateType === 'minor' || !features.hasWindowControls) && (
|
||||
<ForcedUpdateModal updateType={state.updateType} latestVersion={state.latestVersion} />
|
||||
)}
|
||||
</UpdateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ForcedUpdateModal({ updateType, latestVersion }) {
|
||||
const { links, updates } = usePlatform();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!downloading || !updates?.onDownloadProgress) return;
|
||||
updates.onDownloadProgress(({ percent }) => {
|
||||
setProgress(percent);
|
||||
});
|
||||
}, [downloading]);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (updates?.installUpdate) {
|
||||
setDownloading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await updates.installUpdate();
|
||||
} catch (e) {
|
||||
setError('Download failed. Please try again.');
|
||||
setDownloading(false);
|
||||
}
|
||||
} else {
|
||||
links.openExternal(RELEASE_URL);
|
||||
}
|
||||
};
|
||||
|
||||
const isPatch = updateType === 'patch';
|
||||
|
||||
return (
|
||||
<div className="forced-update-overlay">
|
||||
<div className="forced-update-modal">
|
||||
<h2>{isPatch ? 'Update Available' : 'Update Required'}</h2>
|
||||
<p>
|
||||
{isPatch
|
||||
? `A new version (v${latestVersion}) is available.`
|
||||
: `A new version (v${latestVersion}) is available. This update is required to continue using the app.`
|
||||
}
|
||||
</p>
|
||||
{downloading ? (
|
||||
<div className="update-progress">
|
||||
<div className="update-progress-bar-bg">
|
||||
<div
|
||||
className="update-progress-bar-fill"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="update-progress-text">Downloading... {progress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button className="forced-update-btn" onClick={handleDownload}>
|
||||
Download Update
|
||||
</button>
|
||||
)}
|
||||
{error && <p className="update-error-text">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TitleBarUpdateIcon() {
|
||||
const { links, updates } = usePlatform();
|
||||
const update = useUpdateCheck();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!downloading || !updates?.onDownloadProgress) return;
|
||||
updates.onDownloadProgress(({ percent }) => {
|
||||
setProgress(percent);
|
||||
});
|
||||
}, [downloading]);
|
||||
|
||||
if (!update) return null;
|
||||
|
||||
const handleClick = async () => {
|
||||
if (updates?.installUpdate && !downloading) {
|
||||
setDownloading(true);
|
||||
try {
|
||||
await updates.installUpdate();
|
||||
} catch {
|
||||
setDownloading(false);
|
||||
}
|
||||
} else if (!downloading) {
|
||||
links.openExternal(RELEASE_URL);
|
||||
}
|
||||
};
|
||||
|
||||
const label = downloading
|
||||
? `Downloading update... ${progress}%`
|
||||
: `Update available: v${update.latestVersion}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
className="titlebar-btn"
|
||||
onClick={handleClick}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
style={{ borderRight: '1px solid var(--app-frame-border)' }}
|
||||
>
|
||||
<div style={{ marginRight: '12px' }}>
|
||||
<ColoredIcon src={updateIcon} color="#3ba55c" size="20px" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import Avatar from './Avatar';
|
||||
|
||||
const STATUS_LABELS = {
|
||||
online: 'Online',
|
||||
idle: 'Idle',
|
||||
dnd: 'Do Not Disturb',
|
||||
invisible: 'Invisible',
|
||||
offline: 'Offline',
|
||||
};
|
||||
|
||||
const STATUS_COLORS = {
|
||||
online: '#3ba55c',
|
||||
idle: '#faa61a',
|
||||
dnd: '#ed4245',
|
||||
invisible: '#747f8d',
|
||||
offline: '#747f8d',
|
||||
};
|
||||
|
||||
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||
|
||||
function getUserColor(name) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
|
||||
}
|
||||
|
||||
const UserProfilePopup = ({ userId, username, avatarUrl, status, position, onClose, onSendMessage }) => {
|
||||
const popupRef = useRef(null);
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
// Fetch member data (roles, aboutMe) for this user
|
||||
const allUsers = useQuery(api.auth.getPublicKeys) || [];
|
||||
const userData = allUsers.find(u => u.id === userId);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e) => {
|
||||
if (popupRef.current && !popupRef.current.contains(e.target)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [onClose]);
|
||||
|
||||
// Load note from localStorage
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
const saved = localStorage.getItem(`note_${userId}`);
|
||||
if (saved) setNote(saved);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const handleNoteChange = (e) => {
|
||||
const val = e.target.value;
|
||||
setNote(val);
|
||||
if (userId) {
|
||||
localStorage.setItem(`note_${userId}`, val);
|
||||
}
|
||||
};
|
||||
|
||||
const userColor = getUserColor(username || 'Unknown');
|
||||
const userStatus = status || 'online';
|
||||
const resolvedAvatarUrl = avatarUrl || userData?.avatarUrl;
|
||||
const aboutMe = userData?.aboutMe;
|
||||
|
||||
const style = {
|
||||
position: 'fixed',
|
||||
top: Math.min(position.y, window.innerHeight - 420),
|
||||
left: Math.min(position.x, window.innerWidth - 320),
|
||||
zIndex: 10000,
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div ref={popupRef} className="user-profile-popup" style={style}>
|
||||
<div className="user-profile-banner" style={{ backgroundColor: userColor }} />
|
||||
<div className="user-profile-body">
|
||||
<div className="user-profile-avatar-wrapper">
|
||||
<Avatar
|
||||
username={username}
|
||||
avatarUrl={resolvedAvatarUrl}
|
||||
size={64}
|
||||
className="user-profile-avatar"
|
||||
style={{ border: '4px solid var(--background-base-lowest)' }}
|
||||
/>
|
||||
<div
|
||||
className="user-profile-status-dot"
|
||||
style={{ backgroundColor: STATUS_COLORS[userStatus] }}
|
||||
/>
|
||||
</div>
|
||||
<div className="user-profile-name">{userData?.displayName || username}</div>
|
||||
{userData?.displayName && (
|
||||
<div style={{ color: 'var(--header-secondary)', fontSize: '12px', marginTop: '-4px', marginBottom: '4px', paddingLeft: '12px' }}>
|
||||
{username}
|
||||
</div>
|
||||
)}
|
||||
<div className="user-profile-status-text">
|
||||
{userData?.customStatus || STATUS_LABELS[userStatus] || 'Online'}
|
||||
</div>
|
||||
<div className="user-profile-divider" />
|
||||
|
||||
<div className="user-profile-section-header">ABOUT ME</div>
|
||||
<div className="user-profile-about">
|
||||
{aboutMe || 'No information set.'}
|
||||
</div>
|
||||
|
||||
<div className="user-profile-divider" />
|
||||
|
||||
<div className="user-profile-section-header">NOTE</div>
|
||||
<textarea
|
||||
className="user-profile-note-input"
|
||||
placeholder="Click to add a note"
|
||||
value={note}
|
||||
onChange={handleNoteChange}
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
{onSendMessage && (
|
||||
<button
|
||||
className="user-profile-message-btn"
|
||||
onClick={() => { onSendMessage(userId, username); onClose(); }}
|
||||
style={{ marginTop: '8px' }}
|
||||
>
|
||||
Send Message
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default UserProfilePopup;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
165
packages/shared/src/components/auth/AuthLayout.module.css
Normal file
165
packages/shared/src/components/auth/AuthLayout.module.css
Normal file
@@ -0,0 +1,165 @@
|
||||
.container {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
width: 100%;
|
||||
background-color: var(--brand-primary);
|
||||
}
|
||||
|
||||
/* Mobile-only logo banner inside the form column. Hidden by
|
||||
default — only revealed when the desktop side-column is
|
||||
collapsed in the mobile media query below. */
|
||||
.mobileLogo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pattern {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
opacity: 0.06;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='260' height='260' viewBox='0 0 260 260'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Ccircle cx='30' cy='30' r='8'/%3E%3Ccircle cx='130' cy='30' r='6'/%3E%3Ccircle cx='230' cy='30' r='8'/%3E%3Ccircle cx='80' cy='80' r='10'/%3E%3Ccircle cx='180' cy='80' r='6'/%3E%3Ccircle cx='30' cy='130' r='6'/%3E%3Ccircle cx='130' cy='130' r='8'/%3E%3Ccircle cx='230' cy='130' r='10'/%3E%3Ccircle cx='80' cy='180' r='6'/%3E%3Ccircle cx='180' cy='180' r='8'/%3E%3Ccircle cx='30' cy='230' r='10'/%3E%3Ccircle cx='130' cy='230' r='6'/%3E%3Ccircle cx='230' cy='230' r='8'/%3E%3C/g%3E%3C/svg%3E");
|
||||
background-repeat: repeat;
|
||||
background-size: 260px 260px;
|
||||
}
|
||||
|
||||
.cardContainer {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: clamp(2rem, 6vw, 4rem);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
height: auto;
|
||||
min-height: 500px;
|
||||
width: 100%;
|
||||
max-width: 56rem;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
background-color: var(--background-secondary);
|
||||
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
||||
}
|
||||
|
||||
.logoSide {
|
||||
display: flex;
|
||||
width: 33.333%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 2rem;
|
||||
border-right: 1px solid var(--background-modifier-accent);
|
||||
background-color: var(--background-secondary);
|
||||
}
|
||||
|
||||
.logoIcon {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 50%;
|
||||
background-color: var(--brand-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.formSide {
|
||||
display: flex;
|
||||
width: 66.667%;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Strip the purple branded backdrop on mobile — Fluxer's
|
||||
reference is just a flat dark surface with no card chrome.
|
||||
Uses `--background-secondary` so the inputs (which use the
|
||||
darker `--background-tertiary`) visibly recess into the page. */
|
||||
.container {
|
||||
background-color: var(--background-secondary);
|
||||
}
|
||||
|
||||
.pattern {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cardContainer {
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* The card collapses into a borderless full-screen surface and
|
||||
the desktop side column is hidden — its content gets
|
||||
re-rendered inside the form column via the `.mobileLogo`
|
||||
banner so the layout becomes a single vertical stack. */
|
||||
.card {
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
max-width: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background-color: var(--background-secondary);
|
||||
}
|
||||
|
||||
.logoSide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.formSide {
|
||||
width: 100%;
|
||||
padding: 32px 24px 24px;
|
||||
background-color: var(--background-secondary);
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* Reveal the in-form-column logo banner. */
|
||||
.mobileLogo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin: 8px 0 28px;
|
||||
}
|
||||
|
||||
.mobileLogoIcon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--brand-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mobileWordmark {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
}
|
||||
45
packages/shared/src/components/auth/AuthLayout.tsx
Normal file
45
packages/shared/src/components/auth/AuthLayout.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import styles from './AuthLayout.module.css';
|
||||
|
||||
interface AuthLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthLayout — frame for the login / register pages.
|
||||
*
|
||||
* Desktop: keeps the existing side-by-side "card" — purple branded
|
||||
* background, centred dialog with a logo column on the left and
|
||||
* the form on the right.
|
||||
*
|
||||
* Mobile (≤768px): collapses into a full-screen `--background-primary`
|
||||
* surface with the logo + wordmark stacked at the top of the form
|
||||
* column, matching the Fluxer mobile reference. The card chrome,
|
||||
* purple background, and pattern are all hidden via the media
|
||||
* query in `AuthLayout.module.css`.
|
||||
*/
|
||||
export function AuthLayout({ children }: AuthLayoutProps) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.pattern} />
|
||||
<div className={styles.cardContainer}>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.logoSide}>
|
||||
<div className={styles.logoIcon}>B</div>
|
||||
<span className={styles.wordmark}>Brycord</span>
|
||||
</div>
|
||||
<div className={styles.formSide}>
|
||||
{/* Mobile-only logo banner — appears above the form
|
||||
when the side column is hidden. The
|
||||
`data-mobile-logo` attribute is what the CSS
|
||||
media query targets to switch its display. */}
|
||||
<div className={styles.mobileLogo} data-mobile-logo>
|
||||
<div className={styles.mobileLogoIcon}>B</div>
|
||||
<span className={styles.mobileWordmark}>Brycord</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
packages/shared/src/components/auth/InviteAcceptPage.tsx
Normal file
136
packages/shared/src/components/auth/InviteAcceptPage.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* InviteAcceptPage — handles `/invite/:code#key=<secret>`.
|
||||
*
|
||||
* Pulls the encrypted payload from `api.invites.use`, decrypts it
|
||||
* with the URL-fragment secret, stashes the decoded channel-key
|
||||
* map in sessionStorage under `pendingInviteKeys` / `pendingInviteCode`,
|
||||
* then forwards the user to the register page (or to `/channels/@me`
|
||||
* if they're already logged in).
|
||||
*
|
||||
* The fragment secret never hits the server — browsers don't send
|
||||
* `#…` to origin servers — so knowledge of it is the capability that
|
||||
* unlocks the invite payload.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useConvex } from 'convex/react';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import { usePlatform } from '../../platform';
|
||||
import { AuthLayout } from './AuthLayout';
|
||||
|
||||
export function InviteAcceptPage() {
|
||||
const { code } = useParams<{ code: string }>();
|
||||
const navigate = useNavigate();
|
||||
const convex = useConvex();
|
||||
const { crypto } = usePlatform();
|
||||
const [status, setStatus] = useState<'working' | 'error'>('working');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const run = async () => {
|
||||
if (!code) {
|
||||
setStatus('error');
|
||||
setError('Missing invite code.');
|
||||
return;
|
||||
}
|
||||
|
||||
const hash =
|
||||
typeof window !== 'undefined' ? window.location.hash : '';
|
||||
// Fragment looks like "#key=abcd…"
|
||||
const match = hash.match(/[#&]key=([^&]+)/);
|
||||
const secret = match ? match[1] : null;
|
||||
if (!secret) {
|
||||
setStatus('error');
|
||||
setError(
|
||||
'Invite link is missing its secret. Ask the sender for a fresh link.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await convex.query(api.invites.use, { code });
|
||||
if (cancelled) return;
|
||||
if ('error' in result) {
|
||||
setStatus('error');
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Older invites used to send a placeholder empty payload.
|
||||
// Treat that as "no channel keys to import" and continue
|
||||
// so the user can still register with the code.
|
||||
let keys: Record<string, string> = {};
|
||||
if (result.encryptedPayload) {
|
||||
try {
|
||||
const blob = JSON.parse(result.encryptedPayload) as {
|
||||
c: string;
|
||||
i: string;
|
||||
t: string;
|
||||
};
|
||||
const plaintext = await crypto.decryptData(
|
||||
blob.c,
|
||||
secret,
|
||||
blob.i,
|
||||
blob.t,
|
||||
);
|
||||
keys = JSON.parse(plaintext) as Record<string, string>;
|
||||
} catch {
|
||||
setStatus('error');
|
||||
setError(
|
||||
'Failed to decrypt invite payload. The secret may be wrong or the invite corrupted.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sessionStorage.setItem('pendingInviteCode', code);
|
||||
sessionStorage.setItem('pendingInviteKeys', JSON.stringify(keys));
|
||||
} catch {
|
||||
/* storage blocked — flow still works if same tab */
|
||||
}
|
||||
|
||||
// If the user is already signed in, just take them home —
|
||||
// the invite keys are already theirs in that case.
|
||||
const loggedIn = !!sessionStorage.getItem('privateKey');
|
||||
if (loggedIn) {
|
||||
navigate('/channels/@me', { replace: true });
|
||||
} else {
|
||||
navigate('/register', { replace: true });
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (cancelled) return;
|
||||
setStatus('error');
|
||||
setError(err?.message ?? 'Failed to accept invite.');
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, convex, crypto, navigate]);
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<h1 style={{ color: 'var(--text-primary)' }}>
|
||||
{status === 'working' ? 'Accepting invite…' : 'Invite Error'}
|
||||
</h1>
|
||||
{error && (
|
||||
<p
|
||||
style={{
|
||||
color: 'var(--status-danger, #ed4245)',
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default InviteAcceptPage;
|
||||
169
packages/shared/src/components/auth/LoginPage.module.css
Normal file
169
packages/shared/src/components/auth/LoginPage.module.css
Normal file
@@ -0,0 +1,169 @@
|
||||
.title {
|
||||
margin: 0 0 1.5rem;
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/* Wrapper for inputs that have a trailing icon (eye toggle on
|
||||
password fields). The `position: relative` lets the icon be
|
||||
absolutely positioned inside the input area. */
|
||||
.inputWrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
background-color: var(--form-surface-background);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
transition: border-color 0.15s, background-color 0.15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.inputWithTrailing {
|
||||
padding-right: 44px;
|
||||
}
|
||||
|
||||
.input:focus,
|
||||
.input:focus-visible {
|
||||
border-color: var(--brand-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--text-tertiary-muted, var(--text-tertiary));
|
||||
}
|
||||
|
||||
/* Trailing icon button (e.g. password visibility toggle). Sits
|
||||
inside `.inputWrap` and overlaps the right edge of `.input`. */
|
||||
.trailingIconButton {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-primary-muted, #a0a3a8);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.trailingIconButton:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: hsl(0, calc(80% * var(--saturation-factor)), 70%);
|
||||
font-size: 0.875rem;
|
||||
background-color: hsl(0, calc(60% * var(--saturation-factor)), 22%);
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
padding: 0 1rem;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background-color: var(--brand-primary);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: filter 120ms ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.submitButton:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.submitButton:hover:not(:disabled),
|
||||
.submitButton:active:not(:disabled) {
|
||||
filter: brightness(0.92);
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.dividerLine {
|
||||
flex: 1;
|
||||
border: none;
|
||||
border-top: 1px solid var(--background-header-secondary);
|
||||
}
|
||||
|
||||
.dividerText {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary-muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.footerText {
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.footerLink {
|
||||
color: var(--brand-primary-light);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-weight: 700;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.footerLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
158
packages/shared/src/components/auth/LoginPage.tsx
Normal file
158
packages/shared/src/components/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Eye, EyeSlash } from '@phosphor-icons/react';
|
||||
import { useConvex } from 'convex/react';
|
||||
import { usePlatform } from '../../platform';
|
||||
import { useSearch } from '../../contexts/SearchContext';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import { AuthLayout } from './AuthLayout';
|
||||
import styles from './LoginPage.module.css';
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const convex = useConvex();
|
||||
const { crypto, session } = usePlatform();
|
||||
const searchCtx = useSearch();
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function decryptEncryptedField(encryptedJson: string, keyHex: string): Promise<string> {
|
||||
const obj = JSON.parse(encryptedJson);
|
||||
return crypto.decryptData(obj.content, keyHex, obj.iv, obj.tag);
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { salt } = await convex.query(api.auth.getSalt, { username });
|
||||
const { dek, dak } = await crypto.deriveAuthKeys(password, salt);
|
||||
|
||||
const searchKeys = await crypto.deriveAuthKeys(password, 'searchdb-' + username);
|
||||
sessionStorage.setItem('searchDbKey', searchKeys.dak);
|
||||
|
||||
const verifyData = await convex.mutation(api.auth.verifyUser, { username, dak });
|
||||
if (verifyData.error) throw new Error(verifyData.error);
|
||||
|
||||
if (verifyData.userId) localStorage.setItem('userId', verifyData.userId);
|
||||
|
||||
const mkHex = await decryptEncryptedField(verifyData.encryptedMK, dek);
|
||||
sessionStorage.setItem('masterKey', mkHex);
|
||||
|
||||
const encryptedPrivateKeysObj = JSON.parse(verifyData.encryptedPrivateKeys);
|
||||
const signingKey = await decryptEncryptedField(JSON.stringify(encryptedPrivateKeysObj.ed), mkHex);
|
||||
const rsaPriv = await decryptEncryptedField(JSON.stringify(encryptedPrivateKeysObj.rsa), mkHex);
|
||||
|
||||
sessionStorage.setItem('signingKey', signingKey);
|
||||
sessionStorage.setItem('privateKey', rsaPriv);
|
||||
localStorage.setItem('username', username);
|
||||
if (verifyData.publicKey) localStorage.setItem('publicKey', verifyData.publicKey);
|
||||
|
||||
if (session) {
|
||||
try {
|
||||
await session.save({
|
||||
userId: verifyData.userId,
|
||||
username,
|
||||
publicKey: verifyData.publicKey || '',
|
||||
signingKey,
|
||||
privateKey: rsaPriv,
|
||||
masterKey: mkHex,
|
||||
searchDbKey: searchKeys.dak,
|
||||
savedAt: Date.now(),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Session persistence unavailable:', err);
|
||||
}
|
||||
}
|
||||
|
||||
searchCtx?.initialize();
|
||||
navigate('/channels/@me');
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? 'Login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<h1 className={styles.title}>Welcome back</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className={styles.form}>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Username</label>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Password</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className={`${styles.input} ${styles.inputWithTrailing}`}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trailingIconButton}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeSlash size={18} weight="regular" /> : <Eye size={18} weight="regular" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
<button type="submit" className={styles.submitButton} disabled={loading}>
|
||||
{loading ? 'Logging in…' : 'Log in'}
|
||||
</button>
|
||||
|
||||
<div className={styles.divider}>
|
||||
<hr className={styles.dividerLine} />
|
||||
<span className={styles.dividerText}>OR</span>
|
||||
<hr className={styles.dividerLine} />
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<span className={styles.footerText}>Need an account? </span>
|
||||
<button type="button" className={styles.footerLink} onClick={() => navigate('/register')}>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', marginTop: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.footerLink}
|
||||
onClick={() => navigate('/recovery')}
|
||||
>
|
||||
Forgot your password?
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginPage;
|
||||
500
packages/shared/src/components/auth/RegisterPage.tsx
Normal file
500
packages/shared/src/components/auth/RegisterPage.tsx
Normal file
@@ -0,0 +1,500 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { CheckCircle, Eye, EyeSlash, Warning } from '@phosphor-icons/react';
|
||||
import { useConvex, useQuery } from 'convex/react';
|
||||
import { usePlatform } from '../../platform';
|
||||
import { useSearch } from '../../contexts/SearchContext';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import { AuthLayout } from './AuthLayout';
|
||||
import styles from './LoginPage.module.css';
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
const MIN_USERNAME_LENGTH = 2;
|
||||
const USERNAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
||||
|
||||
type InviteState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'checking' }
|
||||
| {
|
||||
status: 'valid';
|
||||
code: string;
|
||||
// Decoded channel-key map, if the pasted link carried a secret.
|
||||
// When the user only pastes a bare code, the map stays null and
|
||||
// the new account just won't have any existing channel keys.
|
||||
keys: Record<string, string> | null;
|
||||
}
|
||||
| { status: 'invalid'; message: string };
|
||||
|
||||
/**
|
||||
* Parse the invite-code field. Accepts either a bare code or a full
|
||||
* invite URL of the form `${origin}/invite/CODE#key=SECRET`.
|
||||
*/
|
||||
function parseInviteInput(raw: string): { code: string; secret: string | null } | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
// Full URL (possibly with a fragment)
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const match = url.pathname.match(/\/invite\/([^/]+)/);
|
||||
if (match) {
|
||||
const code = match[1];
|
||||
const hash = url.hash; // "#key=abcd"
|
||||
const keyMatch = hash.match(/[#&]key=([^&]+)/);
|
||||
return { code, secret: keyMatch ? keyMatch[1] : null };
|
||||
}
|
||||
} catch {
|
||||
/* not a URL — fall through to bare-code path */
|
||||
}
|
||||
// Bare code (treat whitespace-delimited first token as the code)
|
||||
const code = trimmed.split(/\s+/)[0];
|
||||
return { code, secret: null };
|
||||
}
|
||||
|
||||
export function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const convex = useConvex();
|
||||
const { crypto, session } = usePlatform();
|
||||
const searchCtx = useSearch();
|
||||
|
||||
// Used to detect the first-user bootstrap case — before any user
|
||||
// exists, the backend allows an empty invite code. This query is
|
||||
// cheap and public, so it's fine to run during register.
|
||||
const existingUsers = useQuery(api.auth.getPublicKeys) ?? [];
|
||||
const isFirstUser = existingUsers.length === 0;
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [inviteInput, setInviteInput] = useState('');
|
||||
const [invite, setInvite] = useState<InviteState>({ status: 'idle' });
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
|
||||
const debounceRef = useRef<number | null>(null);
|
||||
|
||||
// Pick up an invite already accepted by /invite/:code — if
|
||||
// InviteAcceptPage stashed keys in sessionStorage, we skip the
|
||||
// validation UI and mark the invite as already valid.
|
||||
useEffect(() => {
|
||||
try {
|
||||
const pendingCode = sessionStorage.getItem('pendingInviteCode');
|
||||
const pendingRaw = sessionStorage.getItem('pendingInviteKeys');
|
||||
if (pendingCode) {
|
||||
setInviteInput(pendingCode);
|
||||
if (pendingRaw) {
|
||||
try {
|
||||
const parsed = JSON.parse(pendingRaw) as Record<string, string>;
|
||||
setInvite({ status: 'valid', code: pendingCode, keys: parsed });
|
||||
return;
|
||||
} catch {
|
||||
/* fall through to re-validation */
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Live validation of whatever the user is typing. A 500 ms debounce
|
||||
// keeps us from hammering Convex on every keystroke.
|
||||
useEffect(() => {
|
||||
if (debounceRef.current !== null) {
|
||||
window.clearTimeout(debounceRef.current);
|
||||
debounceRef.current = null;
|
||||
}
|
||||
const trimmed = inviteInput.trim();
|
||||
if (!trimmed) {
|
||||
setInvite({ status: 'idle' });
|
||||
return;
|
||||
}
|
||||
// If this is the code we already validated on mount via
|
||||
// sessionStorage, don't re-fetch.
|
||||
if (invite.status === 'valid' && invite.code === trimmed) return;
|
||||
|
||||
setInvite({ status: 'checking' });
|
||||
debounceRef.current = window.setTimeout(async () => {
|
||||
const parsed = parseInviteInput(trimmed);
|
||||
if (!parsed) {
|
||||
setInvite({ status: 'invalid', message: 'Empty invite.' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await convex.query(api.invites.use, { code: parsed.code });
|
||||
if ('error' in result) {
|
||||
setInvite({ status: 'invalid', message: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
// If a secret was supplied, try to decrypt the payload
|
||||
// right now so we can (a) verify the secret actually
|
||||
// works, and (b) stash the decoded key map for the
|
||||
// submit handler to upload after account creation.
|
||||
let keys: Record<string, string> | null = null;
|
||||
if (parsed.secret && result.encryptedPayload) {
|
||||
try {
|
||||
const blob = JSON.parse(result.encryptedPayload) as {
|
||||
c: string;
|
||||
i: string;
|
||||
t: string;
|
||||
};
|
||||
const plaintext = await crypto.decryptData(
|
||||
blob.c,
|
||||
parsed.secret,
|
||||
blob.i,
|
||||
blob.t,
|
||||
);
|
||||
keys = JSON.parse(plaintext) as Record<string, string>;
|
||||
} catch {
|
||||
setInvite({
|
||||
status: 'invalid',
|
||||
message:
|
||||
'Invite secret does not match. Make sure you pasted the whole link.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
setInvite({ status: 'valid', code: parsed.code, keys });
|
||||
} catch (err: any) {
|
||||
setInvite({
|
||||
status: 'invalid',
|
||||
message: err?.message ?? 'Unable to verify invite.',
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current !== null) {
|
||||
window.clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [inviteInput, convex, crypto]);
|
||||
|
||||
const canSubmit =
|
||||
!isRegistering &&
|
||||
(isFirstUser || invite.status === 'valid') &&
|
||||
username.trim().length >= MIN_USERNAME_LENGTH &&
|
||||
password.length >= MIN_PASSWORD_LENGTH &&
|
||||
password === confirmPassword;
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
const trimmedUsername = username.trim();
|
||||
if (trimmedUsername.length < MIN_USERNAME_LENGTH) {
|
||||
setError(`Username must be at least ${MIN_USERNAME_LENGTH} characters`);
|
||||
return;
|
||||
}
|
||||
if (!USERNAME_PATTERN.test(trimmedUsername)) {
|
||||
setError(
|
||||
'Username may only contain letters, numbers, dots, underscores, or hyphens',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||
setError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`);
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
if (!isFirstUser && invite.status !== 'valid') {
|
||||
setError('You need a valid invite to register.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRegistering(true);
|
||||
|
||||
try {
|
||||
const keys = await crypto.generateKeys();
|
||||
const salt = await crypto.randomBytes(16);
|
||||
const masterKeyHex = await crypto.randomBytes(32);
|
||||
const { dek, dak } = await crypto.deriveAuthKeys(password, salt);
|
||||
const hak = await crypto.sha256(dak);
|
||||
const encryptedMK = JSON.stringify(await crypto.encryptData(masterKeyHex, dek));
|
||||
const encryptedEd = JSON.stringify(
|
||||
await crypto.encryptData(keys.edPriv, masterKeyHex),
|
||||
);
|
||||
const encryptedRsa = JSON.stringify(
|
||||
await crypto.encryptData(keys.rsaPriv, masterKeyHex),
|
||||
);
|
||||
const encryptedPrivateKeys = JSON.stringify({
|
||||
ed: JSON.parse(encryptedEd),
|
||||
rsa: JSON.parse(encryptedRsa),
|
||||
});
|
||||
|
||||
const result = await convex.mutation(api.auth.createUserWithProfile, {
|
||||
username: trimmedUsername,
|
||||
salt,
|
||||
encryptedMK,
|
||||
hak,
|
||||
publicKey: keys.rsaPub,
|
||||
signingKey: keys.edPub,
|
||||
encryptedPrivateKeys,
|
||||
inviteCode: invite.status === 'valid' ? invite.code : undefined,
|
||||
});
|
||||
|
||||
if ('error' in result) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
const searchKeys = await crypto.deriveAuthKeys(
|
||||
password,
|
||||
'searchdb-' + trimmedUsername,
|
||||
);
|
||||
sessionStorage.setItem('searchDbKey', searchKeys.dak);
|
||||
sessionStorage.setItem('masterKey', masterKeyHex);
|
||||
sessionStorage.setItem('signingKey', keys.edPriv);
|
||||
sessionStorage.setItem('privateKey', keys.rsaPriv);
|
||||
localStorage.setItem('userId', result.userId);
|
||||
localStorage.setItem('username', trimmedUsername);
|
||||
localStorage.setItem('publicKey', keys.rsaPub);
|
||||
|
||||
if (session) {
|
||||
try {
|
||||
await session.save({
|
||||
userId: result.userId,
|
||||
username: trimmedUsername,
|
||||
publicKey: keys.rsaPub,
|
||||
signingKey: keys.edPriv,
|
||||
privateKey: keys.rsaPriv,
|
||||
masterKey: masterKeyHex,
|
||||
searchDbKey: searchKeys.dak,
|
||||
savedAt: Date.now(),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Session persistence unavailable:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Upload the decoded invite channel keys. The keys map comes
|
||||
// from the validated invite state (either from direct input
|
||||
// or from InviteAcceptPage via sessionStorage).
|
||||
const inviteKeys =
|
||||
invite.status === 'valid' ? invite.keys : null;
|
||||
try {
|
||||
if (inviteKeys && Object.keys(inviteKeys).length > 0) {
|
||||
const batch: Array<{
|
||||
channelId: string;
|
||||
userId: string;
|
||||
encryptedKeyBundle: string;
|
||||
keyVersion: number;
|
||||
}> = [];
|
||||
for (const [channelId, keyHex] of Object.entries(inviteKeys)) {
|
||||
if (!keyHex) continue;
|
||||
try {
|
||||
const payload = JSON.stringify({ [channelId]: keyHex });
|
||||
const encryptedKeyBundle = await crypto.publicEncrypt(
|
||||
keys.rsaPub,
|
||||
payload,
|
||||
);
|
||||
batch.push({
|
||||
channelId,
|
||||
userId: result.userId,
|
||||
encryptedKeyBundle,
|
||||
keyVersion: 1,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'Failed to encrypt invite key for channel',
|
||||
channelId,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (batch.length > 0) {
|
||||
await convex.mutation(api.channelKeys.uploadKeys, {
|
||||
keys: batch as any,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to import invite keys:', err);
|
||||
} finally {
|
||||
sessionStorage.removeItem('pendingInviteKeys');
|
||||
sessionStorage.removeItem('pendingInviteCode');
|
||||
}
|
||||
|
||||
searchCtx?.initialize();
|
||||
navigate('/channels/@me');
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Registration failed');
|
||||
} finally {
|
||||
setIsRegistering(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<h1 className={styles.title}>Create an account</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className={styles.form}>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Invite Link or Code</label>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
value={inviteInput}
|
||||
onChange={(e) => setInviteInput(e.target.value)}
|
||||
placeholder="Paste your invite link or code"
|
||||
required={!isFirstUser}
|
||||
disabled={isRegistering}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 6,
|
||||
fontSize: 12,
|
||||
minHeight: 18,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{isFirstUser && invite.status === 'idle' && (
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>
|
||||
No users yet — you'll be the first and become admin.
|
||||
</span>
|
||||
)}
|
||||
{invite.status === 'checking' && (
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>
|
||||
Checking invite…
|
||||
</span>
|
||||
)}
|
||||
{invite.status === 'valid' && (
|
||||
<>
|
||||
<CheckCircle
|
||||
size={14}
|
||||
weight="fill"
|
||||
style={{ color: '#3ba55d' }}
|
||||
/>
|
||||
<span style={{ color: '#3ba55d' }}>
|
||||
Invite valid
|
||||
{invite.keys && Object.keys(invite.keys).length > 0
|
||||
? ` — ${Object.keys(invite.keys).length} channel key(s) will be imported`
|
||||
: ''}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{invite.status === 'invalid' && (
|
||||
<>
|
||||
<Warning
|
||||
size={14}
|
||||
weight="fill"
|
||||
style={{ color: 'var(--status-danger, #ed4245)' }}
|
||||
/>
|
||||
<span style={{ color: 'var(--status-danger, #ed4245)' }}>
|
||||
{invite.message}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Username</label>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Choose a username"
|
||||
required
|
||||
disabled={isRegistering}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Password</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className={`${styles.input} ${styles.inputWithTrailing}`}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={`At least ${MIN_PASSWORD_LENGTH} characters`}
|
||||
required
|
||||
disabled={isRegistering}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trailingIconButton}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeSlash size={18} weight="regular" />
|
||||
) : (
|
||||
<Eye size={18} weight="regular" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Confirm Password</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
className={`${styles.input} ${styles.inputWithTrailing}`}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm your password"
|
||||
required
|
||||
disabled={isRegistering}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trailingIconButton}
|
||||
onClick={() => setShowConfirmPassword((v) => !v)}
|
||||
aria-label={
|
||||
showConfirmPassword ? 'Hide password' : 'Show password'
|
||||
}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeSlash size={18} weight="regular" />
|
||||
) : (
|
||||
<Eye size={18} weight="regular" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.submitButton}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{isRegistering ? 'Registering…' : 'Register'}
|
||||
</button>
|
||||
|
||||
<div className={styles.divider}>
|
||||
<hr className={styles.dividerLine} />
|
||||
<span className={styles.dividerText}>OR</span>
|
||||
<hr className={styles.dividerLine} />
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<span className={styles.footerText}>Already have an account? </span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.footerLink}
|
||||
onClick={() => navigate('/login')}
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default RegisterPage;
|
||||
@@ -0,0 +1,199 @@
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.recoveryKeyInput {
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
background-color: var(--background-tertiary);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-family: monospace;
|
||||
outline: none;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.recoveryKeyInput:focus {
|
||||
outline: 2px solid var(--brand-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.recoveryKeyInput::placeholder {
|
||||
color: var(--text-muted);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.recoveryKeyDisplay {
|
||||
background-color: var(--background-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.recoveryKeyText {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
background: var(--brand-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.copyButton:hover {
|
||||
background: var(--brand-primary-hover);
|
||||
}
|
||||
|
||||
.infoBox {
|
||||
background-color: var(--background-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 16px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.infoList {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.infoList li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.warningBox {
|
||||
background-color: rgba(250, 166, 26, 0.1);
|
||||
border: 1px solid rgba(250, 166, 26, 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 16px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--status-warning);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background-color: var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
.dividerText {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
background-color: var(--background-tertiary);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: 2px solid var(--brand-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.passphraseForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--status-danger);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.skipButton {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.skipButton:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.skipButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
27
packages/shared/src/components/auth/SetupEncryptionPage.tsx
Normal file
27
packages/shared/src/components/auth/SetupEncryptionPage.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* SetupEncryptionPage — carried over from the new UI for API parity, but
|
||||
* Discord Clone's Convex auth flow generates + encrypts private keys at
|
||||
* registration time (RegisterPage.tsx) and there is no separate recovery-
|
||||
* key / cross-signing setup. This page is therefore a no-op that simply
|
||||
* continues into the app. It remains exported so any linked route keeps
|
||||
* compiling; nothing currently routes here.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AuthLayout } from './AuthLayout';
|
||||
|
||||
export function SetupEncryptionPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
navigate('/channels/@me', { replace: true });
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<h1 style={{ color: 'var(--text-primary, #fff)' }}>Continuing…</h1>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default SetupEncryptionPage;
|
||||
28
packages/shared/src/components/auth/SplashScreen.module.css
Normal file
28
packages/shared/src/components/auth/SplashScreen.module.css
Normal file
@@ -0,0 +1,28 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
background-color: var(--background-secondary);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
14
packages/shared/src/components/auth/SplashScreen.tsx
Normal file
14
packages/shared/src/components/auth/SplashScreen.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Spinner } from '@brycord/ui';
|
||||
import styles from './SplashScreen.module.css';
|
||||
|
||||
export function SplashScreen() {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<h1 className={styles.logo}>Brycord</h1>
|
||||
<Spinner size={32} />
|
||||
<p className={styles.text}>Connecting securely...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
packages/shared/src/components/auth/index.ts
Normal file
4
packages/shared/src/components/auth/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { LoginPage } from './LoginPage';
|
||||
export { RegisterPage } from './RegisterPage';
|
||||
export { SplashScreen } from './SplashScreen';
|
||||
export { AuthLayout } from './AuthLayout';
|
||||
135
packages/shared/src/components/channel/AccessPicker.module.css
Normal file
135
packages/shared/src/components/channel/AccessPicker.module.css
Normal file
@@ -0,0 +1,135 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.sectionDescription {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.customNotice {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--status-warning, #faa61a);
|
||||
padding: 8px 12px;
|
||||
background-color: color-mix(in srgb, var(--status-warning, #faa61a) 10%, transparent);
|
||||
border-radius: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.optionList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
background-color: var(--background-tertiary, rgba(255, 255, 255, 0.03));
|
||||
border: 1px solid var(--background-modifier-accent, rgba(255, 255, 255, 0.06));
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.option:hover:not(:disabled) {
|
||||
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
|
||||
.option:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.optionSelected {
|
||||
border-color: var(--brand-primary, #5865f2);
|
||||
background-color: color-mix(in srgb, var(--brand-primary, #5865f2) 10%, transparent);
|
||||
}
|
||||
|
||||
.optionSelected:hover:not(:disabled) {
|
||||
background-color: color-mix(in srgb, var(--brand-primary, #5865f2) 15%, transparent);
|
||||
}
|
||||
|
||||
.optionIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background-color: var(--background-secondary, rgba(255, 255, 255, 0.05));
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.optionSelected .optionIcon {
|
||||
background-color: var(--brand-primary, #5865f2);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.optionBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.optionTitle {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.optionDescription {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.35;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.optionCheck {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background-color: var(--brand-primary, #5865f2);
|
||||
color: #ffffff;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.disabledHint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 0.8125rem;
|
||||
margin: 6px 0 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.statusError {
|
||||
color: var(--status-danger, #da373c);
|
||||
}
|
||||
136
packages/shared/src/components/channel/AccessPicker.tsx
Normal file
136
packages/shared/src/components/channel/AccessPicker.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { RoomManager } from '@brycord/matrix-client';
|
||||
import { Check, GlobeHemisphereWest, Lock, UsersThree } from '@phosphor-icons/react';
|
||||
/**
|
||||
* AccessPicker — three-option "who can join" picker for a channel
|
||||
* or category. Mirrors Element's "Invite only / Space members /
|
||||
* Anyone" control and writes the corresponding `m.room.join_rules`
|
||||
* state event via `RoomManager.setChannelAccess`.
|
||||
*
|
||||
* Reads the current access on mount. Writes are optimistic —
|
||||
* selection updates immediately; on server rejection we roll back
|
||||
* and show an inline error. Disabled when the caller passes
|
||||
* `disabled` (e.g., the user lacks `state_default` PL in the
|
||||
* target room).
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import styles from './AccessPicker.module.css';
|
||||
|
||||
export type AccessMode = 'invite' | 'space_members' | 'public';
|
||||
|
||||
interface AccessPickerProps {
|
||||
/** The channel or category whose access we're editing. */
|
||||
roomId: string;
|
||||
/** Parent space — used when writing the `restricted` allow list. */
|
||||
spaceId: string;
|
||||
/** When true, every option is non-interactive. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface OptionDescriptor {
|
||||
value: AccessMode;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const OPTIONS: OptionDescriptor[] = [
|
||||
{
|
||||
value: 'invite',
|
||||
icon: <Lock size={20} weight="fill" />,
|
||||
title: 'Invite Only',
|
||||
description: 'Only invited members can see and join this channel.',
|
||||
},
|
||||
{
|
||||
value: 'space_members',
|
||||
icon: <UsersThree size={20} weight="fill" />,
|
||||
title: 'Space Members',
|
||||
description: 'Anyone in the server can join. Recommended.',
|
||||
},
|
||||
{
|
||||
value: 'public',
|
||||
icon: <GlobeHemisphereWest size={20} weight="fill" />,
|
||||
title: 'Anyone',
|
||||
description: 'Anyone with a link can join, even without a server invite.',
|
||||
},
|
||||
];
|
||||
|
||||
export function AccessPicker({ roomId, spaceId, disabled = false }: AccessPickerProps) {
|
||||
const [current, setCurrent] = useState<AccessMode | 'other' | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Read the current access on mount and whenever the target
|
||||
// room changes. Synchronous — reads from matrix-js-sdk's
|
||||
// in-memory room state.
|
||||
useEffect(() => {
|
||||
const mode = RoomManager.getInstance().getChannelAccess(roomId, spaceId);
|
||||
setCurrent(mode);
|
||||
}, [roomId, spaceId]);
|
||||
|
||||
const handleSelect = async (next: AccessMode) => {
|
||||
if (disabled || saving || next === current) return;
|
||||
const previous = current;
|
||||
// Optimistic — flip the UI immediately, roll back on error.
|
||||
setCurrent(next);
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await RoomManager.getInstance().setChannelAccess(roomId, next, spaceId);
|
||||
} catch (err: any) {
|
||||
setCurrent(previous);
|
||||
setError(err?.message || 'Failed to update access.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// If the room has join rules we don't recognise (e.g. `knock`
|
||||
// or a `restricted` allow list pointing at a different room),
|
||||
// surface that rather than silently overwriting the config.
|
||||
const isCustom = current === 'other';
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.sectionLabel}>Channel Access</div>
|
||||
<p className={styles.sectionDescription}>
|
||||
Control who can join this channel. Existing members always keep their access — this only affects new joins.
|
||||
</p>
|
||||
|
||||
{isCustom && (
|
||||
<p className={styles.customNotice}>
|
||||
Custom join rules are set on this channel. Pick an option below to replace them.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={styles.optionList}>
|
||||
{OPTIONS.map((opt) => {
|
||||
const isSelected = current === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`${styles.option} ${isSelected ? styles.optionSelected : ''}`}
|
||||
onClick={() => handleSelect(opt.value)}
|
||||
disabled={disabled || saving}
|
||||
>
|
||||
<span className={styles.optionIcon}>{opt.icon}</span>
|
||||
<span className={styles.optionBody}>
|
||||
<span className={styles.optionTitle}>{opt.title}</span>
|
||||
<span className={styles.optionDescription}>{opt.description}</span>
|
||||
</span>
|
||||
{isSelected && (
|
||||
<span className={styles.optionCheck}>
|
||||
<Check size={14} weight="bold" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{disabled && <p className={styles.disabledHint}>You need a higher role to change access.</p>}
|
||||
|
||||
{error && <p className={`${styles.status} ${styles.statusError}`}>{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
/* ── Audio player card ───────────────────────────────────────
|
||||
Fluxer-style audio attachment surface. Rounded rectangle with
|
||||
a subtle background, play button on the left, filename + seek
|
||||
bar + time on the right, control row underneath. Width is
|
||||
constrained so the card feels like a compact inline element
|
||||
in a chat message, not a full-width banner. */
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
padding: 14px 16px;
|
||||
background-color: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
border-radius: 0.625rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Top row: play button + filename on the same line ──────── */
|
||||
.topRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.playButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
background-color: var(--brand-primary);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: var(--text-on-brand-primary, #fff);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: filter 0.12s, transform 0.12s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.playButton:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.playButton:active:not(:disabled) {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.playButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Filename row ───────────────────────────────────────────
|
||||
The stem is a single-line ellipsis'd span and the extension
|
||||
is a separate right-flush span, mirroring the Fluxer pattern
|
||||
where the extension always stays visible while the middle of
|
||||
a long filename gets clipped. Lives inline with the play
|
||||
button in the top row; progress bar sits on its own row
|
||||
below so it can span the full card width. */
|
||||
.filenameRow {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.filenameStem {
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filenameExt {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Progress row: seek bar + time label ───────────────────── */
|
||||
.progressRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.progressTrack {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--background-modifier-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.progressFill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: var(--progress, 0%);
|
||||
border-radius: 999px;
|
||||
background-color: var(--brand-primary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Invisible native range input sits on top of the visual track
|
||||
so the user gets native keyboard + drag support for free.
|
||||
The real styling comes from .progressTrack / .progressFill; we
|
||||
just override the thumb to be clickable. */
|
||||
.progressInput {
|
||||
position: absolute;
|
||||
inset: -8px 0;
|
||||
width: 100%;
|
||||
height: calc(100% + 16px);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.progressInput:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.progressInput::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.progressInput::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeLabel {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-primary-muted, #a0a3a8);
|
||||
}
|
||||
|
||||
/* ── Bottom row: volume • speed + favorite + download ──────── */
|
||||
.bottomRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bottomRowRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary-muted, #a0a3a8);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s, color 0.12s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.iconButton:hover:not(:disabled) {
|
||||
background-color: var(--background-modifier-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.iconButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Favorited state — brand-primary fill to match the lightbox
|
||||
star treatment. */
|
||||
.iconButtonActive {
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.iconButtonActive:hover:not(:disabled) {
|
||||
color: var(--brand-primary);
|
||||
background-color: var(--background-modifier-hover);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.speedButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary-muted, #a0a3a8);
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s, color 0.12s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.speedButton:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.hiddenAudio {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Volume slider popover ─────────────────────────────────
|
||||
Speaker button + a hover-revealed vertical slider above it.
|
||||
The popover sits on top of the bottom row, anchored to the
|
||||
button, and fades in when the user hovers either the icon
|
||||
or the popover itself — the shared `.volumeWrap` wrapper
|
||||
keeps the hover state alive across both. */
|
||||
|
||||
.volumeWrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.volumePopover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 44px;
|
||||
padding: 10px 0 14px;
|
||||
background-color: var(--background-floating, #18191c);
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.12s ease, visibility 0.12s ease;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Invisible bridge below the popover so the mouse can move
|
||||
from the speaker icon into the slider without briefly
|
||||
leaving the hover chain and closing the popover. */
|
||||
.volumePopover::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 6px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.volumeWrap:hover .volumePopover,
|
||||
.volumeWrap:focus-within .volumePopover {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Vertical track. 4px wide, 90px tall — same fill-via-CSS-var
|
||||
technique the seek bar uses, just with the axis flipped. */
|
||||
.volumeTrack {
|
||||
position: relative;
|
||||
width: 4px;
|
||||
height: 90px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
.volumeFill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: var(--volume, 0%);
|
||||
border-radius: 999px;
|
||||
background-color: var(--brand-primary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Rotated native range input layered on top of the visual
|
||||
track. The -90deg rotation makes it drag vertically while
|
||||
keeping all the native keyboard + pointer behaviour. The
|
||||
hit area is deliberately larger than the visible track so
|
||||
it's easy to grab without pixel-perfect aim. */
|
||||
.volumeInput {
|
||||
position: absolute;
|
||||
width: 90px;
|
||||
height: 28px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(-90deg);
|
||||
transform-origin: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.volumeInput::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.volumeInput::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
335
packages/shared/src/components/channel/AttachmentAudio.tsx
Normal file
335
packages/shared/src/components/channel/AttachmentAudio.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* AttachmentAudio — custom audio player for `audio/*` attachments.
|
||||
* Ported from the new UI's Fluxer-style layout but simplified for
|
||||
* our Convex pipeline: takes the already-decrypted blob URL from
|
||||
* `EncryptedAttachment` instead of resolving an MXC URL itself.
|
||||
*
|
||||
* ┌──────────────────────────────────────────────────────────┐
|
||||
* │ ┌───┐ filename-truncated… .mp3 │
|
||||
* │ │ ▶ │ ────────────────────────●─── 0:12/3:04│
|
||||
* │ └───┘ │
|
||||
* │ ◀) 1x ↓ │
|
||||
* └──────────────────────────────────────────────────────────┘
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Download,
|
||||
Pause,
|
||||
Play,
|
||||
SpeakerHigh,
|
||||
SpeakerSlash,
|
||||
Star,
|
||||
} from '@phosphor-icons/react';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import type { Id } from '../../../../../convex/_generated/dataModel';
|
||||
import type { AttachmentMetadata } from './EncryptedAttachment';
|
||||
import styles from './AttachmentAudio.module.css';
|
||||
|
||||
interface AttachmentAudioProps {
|
||||
src: string;
|
||||
filename: string;
|
||||
attachment?: AttachmentMetadata;
|
||||
}
|
||||
|
||||
const PLAYBACK_SPEEDS = [1, 1.25, 1.5, 2, 0.5, 0.75];
|
||||
|
||||
function splitFilename(filename: string): { stem: string; ext: string } {
|
||||
const dot = filename.lastIndexOf('.');
|
||||
if (dot <= 0 || dot === filename.length - 1) {
|
||||
return { stem: filename, ext: '' };
|
||||
}
|
||||
return { stem: filename.slice(0, dot), ext: filename.slice(dot) };
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
|
||||
const total = Math.floor(seconds);
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function AttachmentAudio({ src, filename, attachment }: AttachmentAudioProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [playbackRateIndex, setPlaybackRateIndex] = useState(0);
|
||||
|
||||
const { stem, ext } = useMemo(() => splitFilename(filename), [filename]);
|
||||
|
||||
// Saved-media wiring — mirrors the ImageLightbox star button so
|
||||
// audio attachments can be bookmarked into the Media tab.
|
||||
const myUserId =
|
||||
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
|
||||
const savedList = useQuery(
|
||||
api.savedMedia.list,
|
||||
myUserId && attachment
|
||||
? { userId: myUserId as Id<'userProfiles'> }
|
||||
: 'skip',
|
||||
);
|
||||
const isSaved = !!(
|
||||
attachment && savedList?.some((m) => m.url === attachment.url)
|
||||
);
|
||||
const saveMutation = useMutation(api.savedMedia.save);
|
||||
const removeMutation = useMutation(api.savedMedia.remove);
|
||||
|
||||
const handleToggleSaved = useCallback(async () => {
|
||||
if (!attachment || !myUserId) return;
|
||||
try {
|
||||
if (isSaved) {
|
||||
await removeMutation({
|
||||
userId: myUserId as Id<'userProfiles'>,
|
||||
url: attachment.url,
|
||||
});
|
||||
} else {
|
||||
await saveMutation({
|
||||
userId: myUserId as Id<'userProfiles'>,
|
||||
url: attachment.url,
|
||||
kind: attachment.mimeType.split('/')[0],
|
||||
filename: attachment.filename,
|
||||
mimeType: attachment.mimeType,
|
||||
width: attachment.width,
|
||||
height: attachment.height,
|
||||
size: attachment.size,
|
||||
encryptionKey: attachment.key,
|
||||
encryptionIv: attachment.iv,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to toggle saved audio:', err);
|
||||
}
|
||||
}, [attachment, isSaved, myUserId, removeMutation, saveMutation]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
const handleTime = () => setCurrentTime(el.currentTime);
|
||||
const handleDuration = () => setDuration(el.duration);
|
||||
const handlePlay = () => setIsPlaying(true);
|
||||
const handlePause = () => setIsPlaying(false);
|
||||
const handleEnded = () => setIsPlaying(false);
|
||||
const handleVolume = () => {
|
||||
setVolume(el.volume);
|
||||
setIsMuted(el.muted);
|
||||
};
|
||||
el.addEventListener('timeupdate', handleTime);
|
||||
el.addEventListener('loadedmetadata', handleDuration);
|
||||
el.addEventListener('durationchange', handleDuration);
|
||||
el.addEventListener('play', handlePlay);
|
||||
el.addEventListener('pause', handlePause);
|
||||
el.addEventListener('ended', handleEnded);
|
||||
el.addEventListener('volumechange', handleVolume);
|
||||
return () => {
|
||||
el.removeEventListener('timeupdate', handleTime);
|
||||
el.removeEventListener('loadedmetadata', handleDuration);
|
||||
el.removeEventListener('durationchange', handleDuration);
|
||||
el.removeEventListener('play', handlePlay);
|
||||
el.removeEventListener('pause', handlePause);
|
||||
el.removeEventListener('ended', handleEnded);
|
||||
el.removeEventListener('volumechange', handleVolume);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
el.playbackRate = PLAYBACK_SPEEDS[playbackRateIndex];
|
||||
}, [playbackRateIndex]);
|
||||
|
||||
const handleTogglePlay = useCallback(() => {
|
||||
const el = audioRef.current;
|
||||
if (!el || !src) return;
|
||||
if (el.paused) {
|
||||
void el.play().catch(() => {});
|
||||
} else {
|
||||
el.pause();
|
||||
}
|
||||
}, [src]);
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
const next = Number(e.target.value);
|
||||
el.currentTime = next;
|
||||
setCurrentTime(next);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleToggleMute = useCallback(() => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
el.muted = !el.muted;
|
||||
}, []);
|
||||
|
||||
const handleVolumeSlider = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
const next = Number(e.target.value);
|
||||
el.volume = next;
|
||||
if (next > 0 && el.muted) el.muted = false;
|
||||
else if (next === 0 && !el.muted) el.muted = true;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCycleSpeed = useCallback(() => {
|
||||
setPlaybackRateIndex((i) => (i + 1) % PLAYBACK_SPEEDS.length);
|
||||
}, []);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!src) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = src;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}, [src, filename]);
|
||||
|
||||
const progressRatio =
|
||||
duration > 0 ? Math.max(0, Math.min(1, currentTime / duration)) : 0;
|
||||
const progressPercent = `${(progressRatio * 100).toFixed(2)}%`;
|
||||
const playbackSpeedLabel = `${PLAYBACK_SPEEDS[playbackRateIndex]}x`;
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.topRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.playButton}
|
||||
onClick={handleTogglePlay}
|
||||
disabled={!src}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
title={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={18} weight="fill" />
|
||||
) : (
|
||||
<Play size={18} weight="fill" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className={styles.filenameRow}>
|
||||
<span className={styles.filenameStem} title={filename}>
|
||||
{stem}
|
||||
</span>
|
||||
{ext && <span className={styles.filenameExt}>{ext}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.progressRow}>
|
||||
<div
|
||||
className={styles.progressTrack}
|
||||
style={{ ['--progress' as string]: progressPercent }}
|
||||
>
|
||||
<div className={styles.progressFill} />
|
||||
<input
|
||||
type="range"
|
||||
className={styles.progressInput}
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step={0.01}
|
||||
value={currentTime}
|
||||
onChange={handleSeek}
|
||||
disabled={!duration}
|
||||
aria-label="Seek audio"
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.timeLabel}>
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.bottomRow}>
|
||||
<div className={styles.volumeWrap}>
|
||||
<div className={styles.volumePopover}>
|
||||
<div
|
||||
className={styles.volumeTrack}
|
||||
style={{
|
||||
['--volume' as string]: `${(isMuted ? 0 : volume) * 100}%`,
|
||||
}}
|
||||
>
|
||||
<div className={styles.volumeFill} />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={isMuted ? 0 : volume}
|
||||
onChange={handleVolumeSlider}
|
||||
className={styles.volumeInput}
|
||||
aria-label="Volume"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconButton}
|
||||
onClick={handleToggleMute}
|
||||
aria-label={isMuted || volume === 0 ? 'Unmute' : 'Mute'}
|
||||
title={isMuted || volume === 0 ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
{isMuted || volume === 0 ? (
|
||||
<SpeakerSlash size={18} weight="fill" />
|
||||
) : (
|
||||
<SpeakerHigh size={18} weight="fill" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.bottomRowRight}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.speedButton}
|
||||
onClick={handleCycleSpeed}
|
||||
aria-label={`Playback speed: ${playbackSpeedLabel}`}
|
||||
title="Playback speed"
|
||||
>
|
||||
{playbackSpeedLabel}
|
||||
</button>
|
||||
{attachment && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconButton}
|
||||
onClick={() => void handleToggleSaved()}
|
||||
aria-label={isSaved ? 'Unfavorite' : 'Favorite'}
|
||||
title={isSaved ? 'Unfavorite' : 'Favorite'}
|
||||
style={
|
||||
isSaved
|
||||
? { color: 'var(--brand-primary, #5865f2)' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Star size={18} weight={isSaved ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconButton}
|
||||
onClick={handleDownload}
|
||||
disabled={!src}
|
||||
aria-label="Download"
|
||||
title="Download"
|
||||
>
|
||||
<Download size={18} weight="regular" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={src || undefined}
|
||||
preload="metadata"
|
||||
className={styles.hiddenAudio}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/* ── Video attachment card ────────────────────────────────
|
||||
Inline video renderer for `video/*` message attachments.
|
||||
Poster → click → inline playback with a custom control
|
||||
overlay (top hover bar + bottom bar with seek + play +
|
||||
volume + time + fullscreen). */
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
overflow: hidden;
|
||||
background-color: var(--background-secondary);
|
||||
}
|
||||
|
||||
.video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: inherit;
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
/* `object-fit: contain` keeps portrait videos centered inside
|
||||
the 400 × 300 cap instead of being cropped. */
|
||||
object-fit: contain;
|
||||
background-color: #000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Center play overlay (poster state) ───────────────────
|
||||
Shown before the user first clicks play. Fades away once
|
||||
hasStarted is true so the native video surface + custom
|
||||
control bar take over. */
|
||||
.playOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
transition: background-color 0.12s;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.playOverlay:hover {
|
||||
background-color: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.playBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
border: none;
|
||||
color: #fff;
|
||||
/* Nudge the play triangle 2px right so the visual weight
|
||||
sits centered inside the circle — the triangle's
|
||||
geometric centroid is left of its bounding box. */
|
||||
padding-left: 4px;
|
||||
box-sizing: border-box;
|
||||
transition: transform 0.12s;
|
||||
}
|
||||
|
||||
.playOverlay:hover .playBadge {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* ── Top hover bar (Trash / Download / Favorite) ──────────
|
||||
Pinned to the top-right of the card. Hidden at rest,
|
||||
fades in on wrapper hover or focus-within. */
|
||||
.topBar {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.wrapper:hover .topBar,
|
||||
.wrapper:focus-within .topBar {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.topBarButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.topBarButton:hover:not(:disabled) {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.topBarButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Favorited state — brand-primary fill matches the image
|
||||
lightbox's star treatment so the visual language stays
|
||||
consistent across all three attachment viewers. */
|
||||
.topBarButtonActive {
|
||||
background-color: var(--brand-primary);
|
||||
color: var(--text-on-brand-primary, #fff);
|
||||
}
|
||||
|
||||
.topBarButtonActive:hover:not(:disabled) {
|
||||
background-color: var(--brand-primary);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.topBarButtonDanger:hover:not(:disabled) {
|
||||
background-color: hsl(0, calc(70% * var(--saturation-factor, 1)), 55%);
|
||||
}
|
||||
|
||||
/* ── Bottom control bar (after hasStarted) ────────────────
|
||||
Seek bar + play + volume + time + fullscreen. Fades in on
|
||||
wrapper hover so it doesn't clutter a video the user is
|
||||
actively watching at rest. Permanently visible inside
|
||||
fullscreen because the wrapper receives the :hover state
|
||||
from the system cursor whenever it moves. */
|
||||
.bottomBar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 8px 12px 10px;
|
||||
/* Gradient so the white controls stay readable against
|
||||
bright video content without a hard edge. */
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgba(0, 0, 0, 0.75) 0%,
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.wrapperPlaying:hover .bottomBar,
|
||||
.wrapperPlaying:focus-within .bottomBar {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* ── Seek track ──────────────────────────────────────────
|
||||
Native range input layered over a visual track+fill so we
|
||||
get keyboard + drag support for free while keeping full
|
||||
visual control. Same pattern as AttachmentAudio. */
|
||||
.seekTrack {
|
||||
position: relative;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background-color: rgba(255, 255, 255, 0.25);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.seekFill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: var(--progress, 0%);
|
||||
border-radius: 999px;
|
||||
background-color: var(--brand-primary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.seekInput {
|
||||
position: absolute;
|
||||
inset: -8px 0;
|
||||
width: 100%;
|
||||
height: calc(100% + 16px);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.seekInput::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.seekInput::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Control row (play / volume / time / fullscreen) ─────── */
|
||||
.controlsRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.controlButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s;
|
||||
}
|
||||
|
||||
.controlButton:hover {
|
||||
background-color: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.timeLabel {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.controlsSpacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── Spoiler state ────────────────────────────────────────
|
||||
Blurs the poster and swaps the play badge for a SPOILER
|
||||
label. First click reveals, a second click plays — same
|
||||
two-step reveal the image attachment uses. */
|
||||
.wrapperBlurred .video {
|
||||
filter: blur(44px);
|
||||
clip-path: inset(0 round var(--radius-lg, 12px));
|
||||
}
|
||||
|
||||
.wrapperBlurred .playOverlay {
|
||||
background-color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.spoilerCoverLabel {
|
||||
padding: 8px 16px;
|
||||
background-color: rgba(0, 0, 0, 0.75);
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
pointer-events: none;
|
||||
}
|
||||
348
packages/shared/src/components/channel/AttachmentVideo.tsx
Normal file
348
packages/shared/src/components/channel/AttachmentVideo.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* AttachmentVideo — custom video player with Fluxer-style overlay
|
||||
* controls. Ported from the new UI and adapted for our pipeline:
|
||||
* takes the already-decrypted blob URL from `EncryptedAttachment`.
|
||||
*
|
||||
* Two playback states:
|
||||
* 1. Not started → poster + center play button.
|
||||
* 2. Playing → bottom seek bar, play/pause, mute, time, fullscreen.
|
||||
*
|
||||
* Top-right hover bar surfaces Download regardless of playback state.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
CornersIn,
|
||||
CornersOut,
|
||||
Download,
|
||||
Pause,
|
||||
Play,
|
||||
SpeakerHigh,
|
||||
SpeakerSlash,
|
||||
Star,
|
||||
} from '@phosphor-icons/react';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import type { Id } from '../../../../../convex/_generated/dataModel';
|
||||
import type { AttachmentMetadata } from './EncryptedAttachment';
|
||||
import styles from './AttachmentVideo.module.css';
|
||||
|
||||
interface AttachmentVideoProps {
|
||||
src: string;
|
||||
filename: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
attachment?: AttachmentMetadata;
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
|
||||
const total = Math.floor(seconds);
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function AttachmentVideo({
|
||||
src,
|
||||
filename,
|
||||
width,
|
||||
height,
|
||||
attachment,
|
||||
}: AttachmentVideoProps) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
const [hasStarted, setHasStarted] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// Saved-media wiring — mirrors ImageLightbox / AttachmentAudio
|
||||
// so video attachments can be bookmarked into the Media tab.
|
||||
const myUserId =
|
||||
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
|
||||
const savedList = useQuery(
|
||||
api.savedMedia.list,
|
||||
myUserId && attachment
|
||||
? { userId: myUserId as Id<'userProfiles'> }
|
||||
: 'skip',
|
||||
);
|
||||
const isSaved = !!(
|
||||
attachment && savedList?.some((m) => m.url === attachment.url)
|
||||
);
|
||||
const saveMutation = useMutation(api.savedMedia.save);
|
||||
const removeMutation = useMutation(api.savedMedia.remove);
|
||||
|
||||
const handleToggleSaved = useCallback(async () => {
|
||||
if (!attachment || !myUserId) return;
|
||||
try {
|
||||
if (isSaved) {
|
||||
await removeMutation({
|
||||
userId: myUserId as Id<'userProfiles'>,
|
||||
url: attachment.url,
|
||||
});
|
||||
} else {
|
||||
await saveMutation({
|
||||
userId: myUserId as Id<'userProfiles'>,
|
||||
url: attachment.url,
|
||||
kind: attachment.mimeType.split('/')[0],
|
||||
filename: attachment.filename,
|
||||
mimeType: attachment.mimeType,
|
||||
width: attachment.width,
|
||||
height: attachment.height,
|
||||
size: attachment.size,
|
||||
encryptionKey: attachment.key,
|
||||
encryptionIv: attachment.iv,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to toggle saved video:', err);
|
||||
}
|
||||
}, [attachment, isSaved, myUserId, removeMutation, saveMutation]);
|
||||
|
||||
const widthCap = Math.min(width || 400, 400);
|
||||
const heightCap = Math.min(height || 300, 300);
|
||||
|
||||
useEffect(() => {
|
||||
const el = videoRef.current;
|
||||
if (!el) return;
|
||||
const onTime = () => setCurrentTime(el.currentTime);
|
||||
const onDur = () => setDuration(el.duration);
|
||||
const onPlay = () => setIsPlaying(true);
|
||||
const onPause = () => setIsPlaying(false);
|
||||
const onEnded = () => setIsPlaying(false);
|
||||
const onVol = () => {
|
||||
setVolume(el.volume);
|
||||
setIsMuted(el.muted);
|
||||
};
|
||||
el.addEventListener('timeupdate', onTime);
|
||||
el.addEventListener('loadedmetadata', onDur);
|
||||
el.addEventListener('durationchange', onDur);
|
||||
el.addEventListener('play', onPlay);
|
||||
el.addEventListener('pause', onPause);
|
||||
el.addEventListener('ended', onEnded);
|
||||
el.addEventListener('volumechange', onVol);
|
||||
return () => {
|
||||
el.removeEventListener('timeupdate', onTime);
|
||||
el.removeEventListener('loadedmetadata', onDur);
|
||||
el.removeEventListener('durationchange', onDur);
|
||||
el.removeEventListener('play', onPlay);
|
||||
el.removeEventListener('pause', onPause);
|
||||
el.removeEventListener('ended', onEnded);
|
||||
el.removeEventListener('volumechange', onVol);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setIsFullscreen(document.fullscreenElement === wrapperRef.current);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', handler);
|
||||
return () => document.removeEventListener('fullscreenchange', handler);
|
||||
}, []);
|
||||
|
||||
const handleStartPlay = useCallback(() => {
|
||||
const el = videoRef.current;
|
||||
if (!el) return;
|
||||
setHasStarted(true);
|
||||
void el.play().catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleTogglePlay = useCallback(() => {
|
||||
const el = videoRef.current;
|
||||
if (!el) return;
|
||||
if (el.paused) void el.play().catch(() => {});
|
||||
else el.pause();
|
||||
}, []);
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const el = videoRef.current;
|
||||
if (!el) return;
|
||||
const next = Number(e.target.value);
|
||||
el.currentTime = next;
|
||||
setCurrentTime(next);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleToggleMute = useCallback(() => {
|
||||
const el = videoRef.current;
|
||||
if (!el) return;
|
||||
if (el.muted && el.volume === 0) el.volume = 1;
|
||||
el.muted = !el.muted;
|
||||
}, []);
|
||||
|
||||
const handleToggleFullscreen = useCallback(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
if (document.fullscreenElement) {
|
||||
void document.exitFullscreen().catch(() => {});
|
||||
} else {
|
||||
void wrapper.requestFullscreen().catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!src) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = src;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}, [src, filename]);
|
||||
|
||||
const stop =
|
||||
(fn: () => void) =>
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
fn();
|
||||
};
|
||||
|
||||
const progressRatio = duration > 0 ? currentTime / duration : 0;
|
||||
const progressPercent = `${Math.max(0, Math.min(100, progressRatio * 100)).toFixed(2)}%`;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={`${styles.wrapper} ${hasStarted ? styles.wrapperPlaying : ''}`}
|
||||
style={{
|
||||
maxWidth: `min(${widthCap}px, 100%)`,
|
||||
maxHeight: `${heightCap}px`,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src || undefined}
|
||||
className={styles.video}
|
||||
preload="metadata"
|
||||
playsInline
|
||||
muted={!hasStarted}
|
||||
onClick={hasStarted ? handleTogglePlay : undefined}
|
||||
/>
|
||||
|
||||
<div className={styles.topBar}>
|
||||
{attachment && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.topBarButton}
|
||||
onClick={stop(() => void handleToggleSaved())}
|
||||
aria-label={isSaved ? 'Unfavorite' : 'Favorite'}
|
||||
title={isSaved ? 'Unfavorite' : 'Favorite'}
|
||||
style={
|
||||
isSaved
|
||||
? { color: 'var(--brand-primary, #5865f2)' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Star size={18} weight={isSaved ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.topBarButton}
|
||||
onClick={stop(handleDownload)}
|
||||
disabled={!src}
|
||||
aria-label="Download"
|
||||
title="Download"
|
||||
>
|
||||
<Download size={18} weight="regular" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!hasStarted && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.playOverlay}
|
||||
onClick={handleStartPlay}
|
||||
aria-label="Play video"
|
||||
>
|
||||
<span className={styles.playBadge}>
|
||||
<Play size={22} weight="fill" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasStarted && (
|
||||
<div
|
||||
className={styles.bottomBar}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className={styles.seekTrack}
|
||||
style={{ ['--progress' as string]: progressPercent }}
|
||||
>
|
||||
<div className={styles.seekFill} />
|
||||
<input
|
||||
type="range"
|
||||
className={styles.seekInput}
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step={0.01}
|
||||
value={currentTime}
|
||||
onChange={handleSeek}
|
||||
disabled={!duration}
|
||||
aria-label="Seek video"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.controlsRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.controlButton}
|
||||
onClick={handleTogglePlay}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
title={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={18} weight="fill" />
|
||||
) : (
|
||||
<Play size={18} weight="fill" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.controlButton}
|
||||
onClick={handleToggleMute}
|
||||
aria-label={isMuted || volume === 0 ? 'Unmute' : 'Mute'}
|
||||
title={isMuted || volume === 0 ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
{isMuted || volume === 0 ? (
|
||||
<SpeakerSlash size={18} weight="fill" />
|
||||
) : (
|
||||
<SpeakerHigh size={18} weight="fill" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<span className={styles.timeLabel}>
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
|
||||
<div className={styles.controlsSpacer} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.controlButton}
|
||||
onClick={handleToggleFullscreen}
|
||||
aria-label={
|
||||
isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'
|
||||
}
|
||||
title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<CornersIn size={18} weight="bold" />
|
||||
) : (
|
||||
<CornersOut size={18} weight="bold" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background-color: var(--background-secondary-lighter, var(--background-primary));
|
||||
}
|
||||
|
||||
.messagesWrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.inputArea {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
38
packages/shared/src/components/channel/ChannelChatLayout.tsx
Normal file
38
packages/shared/src/components/channel/ChannelChatLayout.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ChannelTextarea } from './ChannelTextarea';
|
||||
import { Messages } from './Messages';
|
||||
import { TypingUsers } from './TypingUsers';
|
||||
import styles from './ChannelChatLayout.module.css';
|
||||
|
||||
interface ChannelChatLayoutProps {
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
interface ReplyState {
|
||||
eventId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export function ChannelChatLayout({ channelId }: ChannelChatLayoutProps) {
|
||||
const [replyTo, setReplyTo] = useState<ReplyState | null>(null);
|
||||
|
||||
const handleReply = useCallback((eventId: string, username: string) => {
|
||||
setReplyTo({ eventId, username });
|
||||
}, []);
|
||||
|
||||
const handleCancelReply = useCallback(() => {
|
||||
setReplyTo(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.messagesWrapper}>
|
||||
<Messages channelId={channelId} onReply={handleReply} />
|
||||
</div>
|
||||
<div className={styles.inputArea}>
|
||||
<TypingUsers channelId={channelId} />
|
||||
<ChannelTextarea channelId={channelId} replyTo={replyTo} onCancelReply={handleCancelReply} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/* ── Channel details drawer — header ─────────────────────────────────── */
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px 12px;
|
||||
border-bottom: 1px solid var(--user-area-divider-color, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
.headerMain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.headerIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--background-tertiary, rgba(255, 255, 255, 0.05));
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.headerTextBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.headerTitleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.headerTitleIcon {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.headerSubtitle {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.headerCloseButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.headerCloseButton:hover {
|
||||
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.04));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Quick actions row (Mute / Search / More) ────────────────────────── */
|
||||
|
||||
.quickActions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.quickAction {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 12px 8px;
|
||||
background-color: var(--background-tertiary, rgba(255, 255, 255, 0.04));
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.quickAction:hover:not(:disabled) {
|
||||
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.quickAction:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.quickActionIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.quickActionLabel {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Tab bar (Members / Pins) ────────────────────────────────────────
|
||||
Flex row of two equal-width tab buttons. Each button is a column
|
||||
layout: icon + label side by side, padded to ~12px tall. The
|
||||
active tab swaps the text + icon colour to --brand-primary-light
|
||||
and draws a 2px underline via an absolutely-positioned ::after
|
||||
that hangs off the bottom edge of the button and overlaps the
|
||||
row's 1px divider. Inactive tabs stay muted. */
|
||||
|
||||
.tabBar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
padding: 0;
|
||||
border-bottom: 1px solid var(--user-area-divider-color, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
.tabButton {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 14px 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary, var(--text-primary-muted));
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
transition: color 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.tabButton svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary, var(--text-primary-muted));
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.tabButtonActive {
|
||||
color: var(--brand-primary-light);
|
||||
}
|
||||
|
||||
.tabButtonActive svg {
|
||||
color: var(--brand-primary-light);
|
||||
}
|
||||
|
||||
/* Active tab underline — a 2px bar drawn at the bottom of the button,
|
||||
translated down 1px so it overlaps and visually replaces the tab
|
||||
row's divider underneath that tab. */
|
||||
.tabButtonActive::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -1px;
|
||||
height: 2px;
|
||||
background-color: var(--brand-primary-light);
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
|
||||
.membersWrapper {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: 16px 20px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Pins tab ───────────────────────────────────────────────────────*/
|
||||
|
||||
.pinsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px 0 16px;
|
||||
}
|
||||
|
||||
.pinsLoading {
|
||||
padding: 16px 20px;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
/* Fluxer-style empty state: flag icon + "You've reached the end"
|
||||
title + explanatory body. Matches the reference screenshot. */
|
||||
.pinsEmpty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 48px 32px 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pinsEmptyIcon {
|
||||
color: var(--text-primary-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.pinsEmptyTitle {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pinsEmptyBody {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary-muted);
|
||||
line-height: 1.4;
|
||||
max-width: 280px;
|
||||
}
|
||||
301
packages/shared/src/components/channel/ChannelDetailsDrawer.tsx
Normal file
301
packages/shared/src/components/channel/ChannelDetailsDrawer.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* ChannelDetailsDrawer — mobile bottom sheet opened by tapping the
|
||||
* channel name in the ChannelHeader. Shows a Members/Pins tab switcher
|
||||
* with the channel's member list and pinned messages.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Users, PushPin } from '@phosphor-icons/react';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { BottomSheet } from '@discord-clone/ui';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import { usePlatform } from '../../platform';
|
||||
import { MemberListContainer } from '../member/MemberListContainer';
|
||||
import { PinnedMessageRow, ReachedEndNotice, type PinnedMessage } from './PinnedMessageRow';
|
||||
import type { AttachmentMetadata } from './EncryptedAttachment';
|
||||
|
||||
type DrawerTab = 'members' | 'pins';
|
||||
|
||||
interface ChannelDetailsDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
channelId: string;
|
||||
channelName?: string;
|
||||
channelType?: string;
|
||||
}
|
||||
|
||||
const tabBarStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
padding: '4px 12px 12px',
|
||||
borderBottom: '1px solid var(--border-subtle, rgba(255,255,255,0.06))',
|
||||
};
|
||||
|
||||
const tabButtonStyle = (active: boolean): React.CSSProperties => ({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 10,
|
||||
border: 'none',
|
||||
background: active ? 'var(--bg-hover, rgba(255,255,255,0.08))' : 'transparent',
|
||||
color: active ? 'var(--text-primary, #fff)' : 'var(--text-muted, #a0a0a8)',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
const tabBodyStyle: React.CSSProperties = {
|
||||
padding: '12px',
|
||||
minHeight: 240,
|
||||
};
|
||||
|
||||
const emptyStateStyle: React.CSSProperties = {
|
||||
padding: '24px 12px',
|
||||
textAlign: 'center',
|
||||
color: 'var(--text-muted, #a0a0a8)',
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
export function ChannelDetailsDrawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
channelId,
|
||||
channelName,
|
||||
channelType,
|
||||
}: ChannelDetailsDrawerProps) {
|
||||
const [activeTab, setActiveTab] = useState<DrawerTab>('members');
|
||||
|
||||
// Reset to Members whenever the drawer re-opens so a previously
|
||||
// selected Pins tab doesn't follow across channel changes.
|
||||
useEffect(() => {
|
||||
if (isOpen) setActiveTab('members');
|
||||
}, [isOpen, channelId]);
|
||||
|
||||
const title = channelName ?? 'Channel';
|
||||
|
||||
return (
|
||||
<BottomSheet isOpen={isOpen} onClose={onClose} title={title}>
|
||||
<div style={tabBarStyle}>
|
||||
<button
|
||||
type="button"
|
||||
style={tabButtonStyle(activeTab === 'members')}
|
||||
onClick={() => setActiveTab('members')}
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'members'}
|
||||
>
|
||||
<Users size={18} weight="fill" />
|
||||
<span>Members</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={tabButtonStyle(activeTab === 'pins')}
|
||||
onClick={() => setActiveTab('pins')}
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'pins'}
|
||||
>
|
||||
<PushPin size={18} weight="fill" />
|
||||
<span>Pins</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={tabBodyStyle}>
|
||||
{activeTab === 'members' ? (
|
||||
<MemberListContainer channelId={channelId} variant="drawer" />
|
||||
) : (
|
||||
<PinsTabContent
|
||||
channelId={channelId}
|
||||
isOpen={isOpen && activeTab === 'pins'}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
{/* channelType currently unused; reserved for future voice-specific UI */}
|
||||
<span style={{ display: 'none' }}>{channelType}</span>
|
||||
</div>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pins tab ────────────────────────────────────────────────────────
|
||||
|
||||
const TAG_LENGTH = 32;
|
||||
|
||||
interface PinsTabContentProps {
|
||||
channelId: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function PinsTabContent({ channelId, isOpen, onClose }: PinsTabContentProps) {
|
||||
const { crypto } = usePlatform();
|
||||
|
||||
const userId =
|
||||
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
|
||||
const privateKeyPem =
|
||||
typeof sessionStorage !== 'undefined'
|
||||
? sessionStorage.getItem('privateKey')
|
||||
: null;
|
||||
|
||||
const allKeys = useQuery(
|
||||
api.channelKeys.getKeysForUser,
|
||||
userId && isOpen ? ({ userId: userId as any } as any) : 'skip',
|
||||
);
|
||||
|
||||
// Merge all encrypted key bundles → { channelId: keyHex }
|
||||
const [channelKey, setChannelKey] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!allKeys || !privateKeyPem) {
|
||||
setChannelKey(null);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
const merged: Record<string, string> = {};
|
||||
for (const item of allKeys) {
|
||||
try {
|
||||
const bundleJson = await crypto.privateDecrypt(
|
||||
privateKeyPem,
|
||||
(item as any).encrypted_key_bundle,
|
||||
);
|
||||
Object.assign(merged, JSON.parse(bundleJson));
|
||||
} catch (err) {
|
||||
console.error('Failed to decrypt key bundle:', err);
|
||||
}
|
||||
}
|
||||
if (cancelled) return;
|
||||
setChannelKey(merged[channelId] ?? null);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [allKeys, privateKeyPem, channelId, crypto]);
|
||||
|
||||
const pinnedRaw = useQuery(
|
||||
api.messages.listPinned,
|
||||
isOpen
|
||||
? ({
|
||||
channelId: channelId as any,
|
||||
userId: (userId as any) ?? undefined,
|
||||
} as any)
|
||||
: 'skip',
|
||||
);
|
||||
|
||||
const [decryptedMap, setDecryptedMap] = useState<Map<string, string>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (!channelKey || !pinnedRaw) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const next = new Map(decryptedMap);
|
||||
let changed = false;
|
||||
for (const msg of pinnedRaw as any[]) {
|
||||
const id = msg.id as string;
|
||||
if (next.has(id)) continue;
|
||||
if (!msg.ciphertext || msg.ciphertext.length < TAG_LENGTH) {
|
||||
next.set(id, '[Invalid Encrypted Message]');
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
const tag = msg.ciphertext.slice(-TAG_LENGTH);
|
||||
const contentHex = msg.ciphertext.slice(0, -TAG_LENGTH);
|
||||
try {
|
||||
const plaintext = await crypto.decryptData(
|
||||
contentHex,
|
||||
channelKey,
|
||||
msg.nonce,
|
||||
tag,
|
||||
);
|
||||
if (cancelled) return;
|
||||
next.set(id, plaintext);
|
||||
changed = true;
|
||||
} catch {
|
||||
next.set(id, '[Unable to decrypt]');
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed && !cancelled) setDecryptedMap(next);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pinnedRaw, channelKey]);
|
||||
|
||||
const pinned: PinnedMessage[] = useMemo(() => {
|
||||
if (!pinnedRaw) return [];
|
||||
return (pinnedRaw as any[])
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const at = a.created_at ? new Date(a.created_at).getTime() : 0;
|
||||
const bt = b.created_at ? new Date(b.created_at).getTime() : 0;
|
||||
return bt - at;
|
||||
})
|
||||
.map((msg) => {
|
||||
const id = msg.id as string;
|
||||
const rawContent = decryptedMap.get(id) ?? '';
|
||||
let text = rawContent;
|
||||
const attachments: AttachmentMetadata[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(rawContent);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const item of parsed) {
|
||||
if (item?.type === 'attachment' && item.url && item.key && item.iv) {
|
||||
attachments.push(item as AttachmentMetadata);
|
||||
}
|
||||
}
|
||||
text = '';
|
||||
} else if (parsed.type === 'attachment' && parsed.url && parsed.key && parsed.iv) {
|
||||
attachments.push(parsed as AttachmentMetadata);
|
||||
text = '';
|
||||
} else if (parsed.text !== undefined) {
|
||||
text = String(parsed.text);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// plain text — leave as-is
|
||||
}
|
||||
return {
|
||||
id,
|
||||
authorName: msg.displayName || msg.username || 'User',
|
||||
authorAvatarUrl: msg.avatarUrl ?? null,
|
||||
content: text,
|
||||
timestamp: msg.created_at
|
||||
? new Date(msg.created_at).getTime()
|
||||
: Date.now(),
|
||||
attachments,
|
||||
} as PinnedMessage;
|
||||
});
|
||||
}, [pinnedRaw, decryptedMap]);
|
||||
|
||||
const handleJumpTo = (messageId: string) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('brycord:scroll-to-message', {
|
||||
detail: { channelId, messageId },
|
||||
}),
|
||||
);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (pinnedRaw === undefined) {
|
||||
return <div style={emptyStateStyle}>Loading pinned messages…</div>;
|
||||
}
|
||||
|
||||
if (pinned.length === 0) {
|
||||
return <div style={emptyStateStyle}>No pinned messages yet.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{pinned.map((msg) => (
|
||||
<PinnedMessageRow
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
onClick={() => handleJumpTo(msg.id)}
|
||||
/>
|
||||
))}
|
||||
<ReachedEndNotice />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
405
packages/shared/src/components/channel/ChannelHeader.module.css
Normal file
405
packages/shared/src/components/channel/ChannelHeader.module.css
Normal file
@@ -0,0 +1,405 @@
|
||||
.container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
height: var(--layout-header-height, 3.5rem);
|
||||
min-height: var(--layout-header-height, 3.5rem);
|
||||
padding: 0 16px;
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid var(--user-area-divider-color);
|
||||
background-color: var(--background-secondary-lighter, var(--background-primary));
|
||||
z-index: var(--z-index-elevated-3, 30);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.channelInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Desktop 1:1 DM — converts the channelInfo row into a tappable
|
||||
button that opens the other user's profile modal. Resets the
|
||||
native button chrome and adds a subtle hover background so the
|
||||
click target reads as interactive. */
|
||||
.channelInfoClickable {
|
||||
padding: 4px 8px;
|
||||
margin-left: -8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-md, 6px);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.channelInfoClickable:hover {
|
||||
background-color: color-mix(in srgb, var(--interactive-normal) 10%, transparent);
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--channel-icon, var(--text-muted));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Wrapper for the small user-avatar rendered in place of the
|
||||
channel icon when viewing a 1:1 DM. Zeroes the layout chrome
|
||||
around the Avatar so it sits flush in the header row like the
|
||||
Hash icon it replaces. */
|
||||
.dmAvatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
/* Mobile-only back button — appears on the left of the channel title
|
||||
when SelectionStore.isMobileViewport is true. Navigates to the parent
|
||||
space's channel list (or the DM list). */
|
||||
.backButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-right: 4px;
|
||||
margin-left: -8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-md, 6px);
|
||||
color: var(--interactive-normal);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.backButton:hover {
|
||||
background-color: color-mix(in srgb, var(--interactive-normal) 10%, transparent);
|
||||
color: var(--interactive-hover);
|
||||
}
|
||||
|
||||
/* Mobile-only channel info wrapper — holds the back button + the
|
||||
tappable channel-name-with-caret button side by side. */
|
||||
.channelInfoMobile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.channelInfoButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-md, 6px);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.channelInfoButton:hover {
|
||||
background-color: color-mix(in srgb, var(--interactive-normal) 10%, transparent);
|
||||
}
|
||||
|
||||
.channelInfoButton .name {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.caretRight {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.5rem;
|
||||
max-height: 1.5rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background-color: var(--background-modifier-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topic {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.headerButtons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.headerButton {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--interactive-normal);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.headerButton:hover {
|
||||
background-color: color-mix(in srgb, var(--interactive-normal) 10%, transparent);
|
||||
color: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.headerButtonActive {
|
||||
color: var(--interactive-active);
|
||||
}
|
||||
|
||||
/* Circular-background variant used for the DM Phone / Video call
|
||||
buttons. Matches the Fluxer reference image where the icons sit
|
||||
inside a solid dark circle. Uses --guild-list-foreground (same
|
||||
shade as the empty server icon and the DM sidebar action pills)
|
||||
for visual consistency with the rest of the DM chrome. */
|
||||
.headerButtonCircle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--guild-list-foreground);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.headerButtonCircle:hover {
|
||||
background-color: var(--guild-list-foreground);
|
||||
filter: brightness(1.15);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Disabled appearance for the members-list toggle at narrow viewports.
|
||||
We use aria-disabled rather than the native disabled attribute so the
|
||||
Tooltip wrapper can still fire on hover and explain WHY the button is
|
||||
unavailable. The class below overrides the hover state so it doesn't
|
||||
lie about being interactive. */
|
||||
.headerButtonDisabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
color: var(--interactive-normal);
|
||||
}
|
||||
|
||||
.headerButtonDisabled:hover {
|
||||
background: transparent;
|
||||
color: var(--interactive-normal);
|
||||
}
|
||||
|
||||
/* ── Search bar (always visible) ─────────────────────────────── */
|
||||
|
||||
.searchBarWrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.searchBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 244px;
|
||||
height: 36px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--radius-xl, 12px);
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
background-color: var(--background-tertiary);
|
||||
transition: border-color 0.1s;
|
||||
}
|
||||
|
||||
.searchBar:focus-within {
|
||||
border-color: var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
.searchBarIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.searchBarInput {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.searchBarInput:focus,
|
||||
.searchBarInput:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.searchBarInput::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.searchBarClear {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.searchBarClear:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Filter dropdown ─────────────────────────────────────────── */
|
||||
|
||||
.filterDropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 380px;
|
||||
border-radius: var(--radius-xl, 12px);
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
background-color: var(--background-floating, var(--background-tertiary));
|
||||
box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.2), 0 8px 24px rgba(0, 0, 0, 0.28);
|
||||
z-index: 1000;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.filterSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filterSectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.filterSectionIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.filterOption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-md);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
|
||||
.filterOption:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.filterBadge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--background-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filterDesc {
|
||||
flex: 1;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.filterPlus {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.filterOption:hover .filterPlus {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dmHeaderButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 10px 4px 4px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dmHeaderButton:hover {
|
||||
background: var(--background-modifier-hover, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
.dmHeaderName {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #fff);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
497
packages/shared/src/components/channel/ChannelHeader.tsx
Normal file
497
packages/shared/src/components/channel/ChannelHeader.tsx
Normal file
@@ -0,0 +1,497 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
CaretRight,
|
||||
Funnel,
|
||||
Hash,
|
||||
MagnifyingGlass,
|
||||
Phone,
|
||||
Plus,
|
||||
PushPin,
|
||||
SpeakerHigh,
|
||||
Users,
|
||||
VideoCamera,
|
||||
X,
|
||||
} from '@phosphor-icons/react';
|
||||
import { Avatar, Tooltip } from '@discord-clone/ui';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { usePlatform } from '../../platform';
|
||||
import { useOnlineUsers } from '../../contexts/PresenceContext';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import { MemberProfileModal } from '../member/MemberProfileModal';
|
||||
import { MobileMemberProfileSheet } from '../member/MobileMemberProfileSheet';
|
||||
import { useKeybinds } from '../../contexts/KeybindContext';
|
||||
import { ChannelHeaderPinsPopover } from './ChannelHeaderPinsPopover';
|
||||
import styles from './ChannelHeader.module.css';
|
||||
|
||||
const SEARCH_FILTERS = [
|
||||
{ key: 'from:', desc: 'user' },
|
||||
{ key: 'mentions:', desc: 'user' },
|
||||
{ key: 'has:', desc: 'link, embed or file' },
|
||||
{ key: 'before:', desc: 'specific date' },
|
||||
{ key: 'during:', desc: 'specific date' },
|
||||
{ key: 'after:', desc: 'specific date' },
|
||||
{ key: 'pinned:', desc: 'true or false' },
|
||||
];
|
||||
|
||||
interface ChannelLike {
|
||||
_id?: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
topic?: string;
|
||||
}
|
||||
|
||||
interface ChannelHeaderProps {
|
||||
channel?: ChannelLike | null;
|
||||
serverId?: string;
|
||||
onOpenChannelDetails?: () => void;
|
||||
onOpenSearchDrawer?: () => void;
|
||||
membersVisible?: boolean;
|
||||
onToggleMembers?: () => void;
|
||||
/** Hide the members button entirely. DMs use this since a 1:1
|
||||
* conversation doesn't need a sidebar member list. */
|
||||
hideMembersButton?: boolean;
|
||||
/** When true, the viewport is below the members-list breakpoint. The
|
||||
* Users button becomes a no-op and gets disabled styling. */
|
||||
isNarrow?: boolean;
|
||||
/** Controlled search input. State lives in ChannelView so the sibling
|
||||
* SearchPanel can read the same query. */
|
||||
searchQuery?: string;
|
||||
onSearchChange?: (next: string) => void;
|
||||
onSearchClear?: () => void;
|
||||
}
|
||||
|
||||
export function ChannelHeader({
|
||||
channel,
|
||||
onOpenChannelDetails,
|
||||
membersVisible = true,
|
||||
onToggleMembers,
|
||||
hideMembersButton = false,
|
||||
isNarrow = false,
|
||||
searchQuery = '',
|
||||
onSearchChange,
|
||||
onSearchClear,
|
||||
}: ChannelHeaderProps) {
|
||||
const isVoice = channel?.type === 'voice';
|
||||
const isDM = channel?.type === 'dm';
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { crypto } = usePlatform();
|
||||
const { resolveStatus } = useOnlineUsers();
|
||||
const keybinds = useKeybinds();
|
||||
|
||||
const pinsButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const dmHeaderButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [pinsAnchor, setPinsAnchor] = useState<DOMRect | null>(null);
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ── DM participant lookup ──────────────────────────────────────
|
||||
// When this channel is a DM we pull the other participant out of
|
||||
// `api.dms.listDMs` and resolve their profile via getPublicKeys.
|
||||
const myUserId =
|
||||
typeof localStorage !== 'undefined'
|
||||
? localStorage.getItem('userId')
|
||||
: null;
|
||||
const dmRows = useQuery(
|
||||
api.dms.listDMs,
|
||||
isDM && myUserId ? { userId: myUserId as any } : 'skip',
|
||||
);
|
||||
const allUsers = useQuery(
|
||||
api.auth.getPublicKeys,
|
||||
isDM ? {} : 'skip',
|
||||
) ?? [];
|
||||
|
||||
const otherParticipant = useMemo(() => {
|
||||
if (!isDM || !dmRows || !channel?._id) return null;
|
||||
const row = (dmRows as any[]).find((r) => r.channel_id === channel._id);
|
||||
if (!row) return null;
|
||||
const profile = allUsers.find((u) => u.id === row.other_user_id);
|
||||
const storedStatus =
|
||||
(profile?.status as string | undefined) ||
|
||||
(row.other_user_status as string | undefined) ||
|
||||
'offline';
|
||||
const liveStatus = resolveStatus(storedStatus, row.other_user_id);
|
||||
// DM header uses the raw username (not the server display
|
||||
// name) so people always know who they're actually talking to.
|
||||
const username =
|
||||
(profile?.username as string | undefined) ||
|
||||
(row.other_username as string | undefined) ||
|
||||
'user';
|
||||
return {
|
||||
userId: row.other_user_id as string,
|
||||
displayName: username,
|
||||
username,
|
||||
avatarUrl: profile?.avatarUrl ?? row.other_user_avatar_url ?? null,
|
||||
status: liveStatus,
|
||||
};
|
||||
}, [isDM, dmRows, allUsers, channel?._id, resolveStatus]);
|
||||
|
||||
const rotateDMKey = useMutation(api.channelKeys.rotateDMKey);
|
||||
|
||||
const handleRotateDMKey = async () => {
|
||||
if (!channel?._id || !otherParticipant || !myUserId) {
|
||||
throw new Error("Can't rotate — missing DM context.");
|
||||
}
|
||||
const privateKey =
|
||||
typeof sessionStorage !== 'undefined'
|
||||
? sessionStorage.getItem('privateKey')
|
||||
: null;
|
||||
if (!privateKey) {
|
||||
throw new Error('No session key available.');
|
||||
}
|
||||
const me = allUsers.find((u) => u.id === myUserId);
|
||||
const other = allUsers.find((u) => u.id === otherParticipant.userId);
|
||||
if (!me?.public_identity_key || !other?.public_identity_key) {
|
||||
throw new Error("One participant's public key is missing.");
|
||||
}
|
||||
const newKeyHex = await crypto.randomBytes(32);
|
||||
const plaintext = JSON.stringify({ [channel._id]: newKeyHex });
|
||||
const [myBundle, otherBundle] = await Promise.all([
|
||||
crypto.publicEncrypt(me.public_identity_key, plaintext),
|
||||
crypto.publicEncrypt(other.public_identity_key, plaintext),
|
||||
]);
|
||||
await rotateDMKey({
|
||||
channelId: channel._id as any,
|
||||
initiatorUserId: myUserId as any,
|
||||
entries: [
|
||||
{ userId: myUserId as any, encryptedKeyBundle: myBundle },
|
||||
{
|
||||
userId: otherParticipant.userId as any,
|
||||
encryptedKeyBundle: otherBundle,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenProfile = () => {
|
||||
setProfileOpen(true);
|
||||
};
|
||||
|
||||
// Wire keybinds that affect this header: toggle pins popover,
|
||||
// toggle member list. The KeybindProvider dispatches
|
||||
// `brycord:keybind:<id>` events on the window.
|
||||
useEffect(() => {
|
||||
const onTogglePins = () => handleTogglePins();
|
||||
const onToggleMembers = () => {
|
||||
if (hideMembersButton) return;
|
||||
handleToggleMembers();
|
||||
};
|
||||
window.addEventListener('brycord:keybind:popouts.openPins', onTogglePins);
|
||||
window.addEventListener(
|
||||
'brycord:keybind:popouts.toggleMembers',
|
||||
onToggleMembers,
|
||||
);
|
||||
return () => {
|
||||
window.removeEventListener('brycord:keybind:popouts.openPins', onTogglePins);
|
||||
window.removeEventListener(
|
||||
'brycord:keybind:popouts.toggleMembers',
|
||||
onToggleMembers,
|
||||
);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hideMembersButton]);
|
||||
|
||||
const setSearchQuery = (next: string) => {
|
||||
onSearchChange?.(next);
|
||||
};
|
||||
const clearSearchQuery = () => {
|
||||
if (onSearchClear) onSearchClear();
|
||||
else onSearchChange?.('');
|
||||
};
|
||||
|
||||
const onInputBlur = () => {
|
||||
// 200ms delay — long enough for the chip click to register
|
||||
setTimeout(() => setShowFilters(false), 200);
|
||||
};
|
||||
|
||||
const handleFilterClick = (key: string) => {
|
||||
setSearchQuery(searchQuery ? `${searchQuery} ${key}` : key);
|
||||
requestAnimationFrame(() => searchInputRef.current?.focus());
|
||||
};
|
||||
|
||||
/**
|
||||
* On mobile, the back button strips the trailing `/channelId` segment
|
||||
* from the current path so we return to the channel list view. For
|
||||
* `/channels/home/:id` this yields `/channels/home`; for
|
||||
* `/channels/@me/:id` → `/channels/@me`. Falls back to `/channels/@me`
|
||||
* if the path doesn't look like a channel URL.
|
||||
*/
|
||||
const handleMobileBack = () => {
|
||||
const path = location.pathname;
|
||||
const match = path.match(/^(\/channels\/(?:home|@me|[^/]+))(?:\/[^/]+)?$/);
|
||||
navigate(match ? match[1] : '/channels/@me');
|
||||
};
|
||||
|
||||
const handleTogglePins = () => {
|
||||
if (pinsAnchor) {
|
||||
setPinsAnchor(null);
|
||||
return;
|
||||
}
|
||||
const rect = pinsButtonRef.current?.getBoundingClientRect();
|
||||
if (rect) setPinsAnchor(rect);
|
||||
};
|
||||
|
||||
const handleToggleMembers = () => {
|
||||
if (isNarrow) return;
|
||||
if (onToggleMembers) {
|
||||
onToggleMembers();
|
||||
} else {
|
||||
window.dispatchEvent(new CustomEvent('brycord:toggle-members'));
|
||||
}
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.channelInfoMobile}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backButton}
|
||||
onClick={handleMobileBack}
|
||||
aria-label="Back to channels"
|
||||
>
|
||||
<ArrowLeft size={20} weight="bold" />
|
||||
</button>
|
||||
{isDM && otherParticipant ? (
|
||||
<button
|
||||
ref={dmHeaderButtonRef}
|
||||
type="button"
|
||||
className={styles.channelInfoButton}
|
||||
onClick={handleOpenProfile}
|
||||
>
|
||||
<Avatar
|
||||
src={otherParticipant.avatarUrl}
|
||||
size={26}
|
||||
fallback={otherParticipant.displayName}
|
||||
status={otherParticipant.status as any}
|
||||
/>
|
||||
<span className={styles.name}>
|
||||
{otherParticipant.displayName}
|
||||
</span>
|
||||
<CaretRight size={14} weight="bold" className={styles.caretRight} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.channelInfoButton}
|
||||
onClick={onOpenChannelDetails}
|
||||
>
|
||||
{isVoice ? (
|
||||
<SpeakerHigh size={22} className={styles.icon} />
|
||||
) : (
|
||||
<Hash size={22} weight="bold" className={styles.icon} />
|
||||
)}
|
||||
<span className={styles.name}>{channel?.name || 'Channel'}</span>
|
||||
<CaretRight size={14} weight="bold" className={styles.caretRight} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{otherParticipant && (
|
||||
<MemberProfileModal
|
||||
isOpen={profileOpen}
|
||||
onClose={() => setProfileOpen(false)}
|
||||
member={{ userId: otherParticipant.userId }}
|
||||
onRotateKey={handleRotateDMKey}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{isDM && otherParticipant ? (
|
||||
<button
|
||||
ref={dmHeaderButtonRef}
|
||||
type="button"
|
||||
className={styles.dmHeaderButton}
|
||||
onClick={handleOpenProfile}
|
||||
aria-label={`Open ${otherParticipant.displayName}'s profile`}
|
||||
>
|
||||
<Avatar
|
||||
src={otherParticipant.avatarUrl}
|
||||
size={28}
|
||||
fallback={otherParticipant.displayName}
|
||||
status={otherParticipant.status as any}
|
||||
/>
|
||||
<span className={styles.dmHeaderName}>
|
||||
{otherParticipant.displayName}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className={styles.channelInfo}>
|
||||
{isVoice ? (
|
||||
<SpeakerHigh size={20} className={styles.icon} />
|
||||
) : (
|
||||
<Hash size={20} weight="bold" className={styles.icon} />
|
||||
)}
|
||||
<span className={styles.name}>{channel?.name || 'Channel'}</span>
|
||||
{channel?.topic && (
|
||||
<>
|
||||
<div className={styles.divider} />
|
||||
<span className={styles.topic}>{channel.topic}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.headerButtons}>
|
||||
{isDM && (
|
||||
<>
|
||||
<Tooltip content="Start Voice Call" placement="bottom">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.headerButton}
|
||||
aria-label="Start Voice Call"
|
||||
disabled
|
||||
title="Voice calls coming soon"
|
||||
>
|
||||
<Phone size={20} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Start Video Call" placement="bottom">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.headerButton}
|
||||
aria-label="Start Video Call"
|
||||
disabled
|
||||
title="Video calls coming soon"
|
||||
>
|
||||
<VideoCamera size={20} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<Tooltip
|
||||
content="Pinned Messages"
|
||||
shortcut={keybinds.getCombo('popouts.openPins')}
|
||||
placement="bottom"
|
||||
>
|
||||
<button
|
||||
ref={pinsButtonRef}
|
||||
type="button"
|
||||
className={`${styles.headerButton} ${pinsAnchor ? styles.headerButtonActive : ''}`}
|
||||
aria-label="Pinned"
|
||||
onClick={handleTogglePins}
|
||||
>
|
||||
<PushPin size={20} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{!hideMembersButton && (
|
||||
<Tooltip
|
||||
content={
|
||||
isNarrow
|
||||
? 'Window too narrow for member list'
|
||||
: 'Member List'
|
||||
}
|
||||
shortcut={
|
||||
isNarrow ? undefined : keybinds.getCombo('popouts.toggleMembers')
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.headerButton} ${
|
||||
membersVisible && !isNarrow ? styles.headerButtonActive : ''
|
||||
} ${isNarrow ? styles.headerButtonDisabled : ''}`}
|
||||
aria-label="Members"
|
||||
aria-disabled={isNarrow || undefined}
|
||||
onClick={handleToggleMembers}
|
||||
>
|
||||
<Users size={20} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<div className={styles.searchBarWrapper}>
|
||||
<div className={styles.searchBar}>
|
||||
<MagnifyingGlass
|
||||
size={16}
|
||||
weight="regular"
|
||||
className={styles.searchBarIcon}
|
||||
/>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className={styles.searchBarInput}
|
||||
placeholder="Search messages"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onFocus={() => setShowFilters(true)}
|
||||
onBlur={onInputBlur}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.searchBarClear}
|
||||
onClick={clearSearchQuery}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showFilters && !searchQuery && (
|
||||
<div className={styles.filterDropdown}>
|
||||
<div className={styles.filterSection}>
|
||||
<div className={styles.filterSectionHeader}>
|
||||
<span className={styles.filterSectionIcon}>
|
||||
<Funnel size={12} />
|
||||
</span>
|
||||
<span>Search Filters</span>
|
||||
</div>
|
||||
{SEARCH_FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
className={styles.filterOption}
|
||||
onMouseDown={(e) => {
|
||||
// Use mousedown not click so it fires before the input blur
|
||||
e.preventDefault();
|
||||
handleFilterClick(f.key);
|
||||
}}
|
||||
>
|
||||
<span className={styles.filterBadge}>{f.key}</span>
|
||||
<span className={styles.filterDesc}>— {f.desc}</span>
|
||||
<Plus size={14} className={styles.filterPlus} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{channel?._id && (
|
||||
<ChannelHeaderPinsPopover
|
||||
isOpen={pinsAnchor !== null}
|
||||
anchorRect={pinsAnchor}
|
||||
channelId={channel._id}
|
||||
onClose={() => setPinsAnchor(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{otherParticipant && (
|
||||
isMobile ? (
|
||||
<MobileMemberProfileSheet
|
||||
isOpen={profileOpen}
|
||||
onClose={() => setProfileOpen(false)}
|
||||
member={{ userId: otherParticipant.userId }}
|
||||
onRotateKey={handleRotateDMKey}
|
||||
/>
|
||||
) : (
|
||||
<MemberProfileModal
|
||||
isOpen={profileOpen}
|
||||
onClose={() => setProfileOpen(false)}
|
||||
member={{ userId: otherParticipant.userId }}
|
||||
onRotateKey={handleRotateDMKey}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user