feat: Add new emoji assets and an UpdateBanner component.
Some checks failed
Build and Release / build-and-release (push) Failing after 3m28s

This commit is contained in:
Bryan1029384756
2026-02-13 12:20:40 -06:00
parent 63d4208933
commit fe869a3222
3855 changed files with 10226 additions and 15543 deletions

View File

@@ -0,0 +1,307 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useQuery, useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import Sidebar from '../components/Sidebar';
import ChatArea from '../components/ChatArea';
import VoiceStage from '../components/VoiceStage';
import FloatingStreamPiP from '../components/FloatingStreamPiP';
import { useVoice } from '../contexts/VoiceContext';
import FriendsView from '../components/FriendsView';
import MembersList from '../components/MembersList';
import ChatHeader from '../components/ChatHeader';
import { useToasts } from '../components/Toast';
import { PresenceProvider } from '../contexts/PresenceContext';
import { getUserPref, setUserPref } from '../utils/userPreferences';
import { usePlatform } from '../platform';
const Chat = () => {
const { crypto, settings } = usePlatform();
const [userId, setUserId] = useState(() => localStorage.getItem('userId'));
const [username, setUsername] = useState(() => localStorage.getItem('username') || '');
const [view, setView] = useState(() => {
const id = localStorage.getItem('userId');
return id ? getUserPref(id, 'lastView', 'server') : 'server';
});
const [activeChannel, setActiveChannel] = useState(null);
const [channelKeys, setChannelKeys] = useState({});
const [activeDMChannel, setActiveDMChannel] = useState(null);
const [showMembers, setShowMembers] = useState(true);
const [showPinned, setShowPinned] = useState(false);
const convex = useConvex();
const { toasts, addToast, removeToast, ToastContainer } = useToasts();
const prevDmChannelsRef = useRef(null);
const { toggleMute } = useVoice();
// Keyboard shortcuts
useEffect(() => {
const handler = (e) => {
if (e.ctrlKey && e.key === 'k') {
e.preventDefault();
// Quick switcher placeholder - could open a search modal
}
if (e.ctrlKey && e.shiftKey && e.key === 'M') {
e.preventDefault();
toggleMute();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [toggleMute]);
const channels = useQuery(api.channels.list) || [];
const categories = useQuery(api.categories.list) || [];
const serverSettings = useQuery(api.serverSettings.get);
const serverName = serverSettings?.serverName || 'Secure Chat';
const serverIconUrl = serverSettings?.iconUrl || null;
const rawChannelKeys = useQuery(
api.channelKeys.getKeysForUser,
userId ? { userId } : "skip"
);
const dmChannels = useQuery(
api.dms.listDMs,
userId ? { userId } : "skip"
) || [];
useEffect(() => {
if (!rawChannelKeys || rawChannelKeys.length === 0) return;
const privateKey = sessionStorage.getItem('privateKey');
if (!privateKey) return;
async function decryptKeys() {
const keys = {};
for (const item of rawChannelKeys) {
try {
const bundleJson = await crypto.privateDecrypt(privateKey, item.encrypted_key_bundle);
Object.assign(keys, JSON.parse(bundleJson));
} catch (e) {
console.error(`Failed to decrypt keys for channel ${item.channel_id}`, e);
}
}
setChannelKeys(keys);
}
decryptKeys();
}, [rawChannelKeys]);
useEffect(() => {
if (activeChannel || channels.length === 0) return;
// Try to restore last active channel
if (userId) {
const savedChannel = getUserPref(userId, 'lastActiveChannel', null);
if (savedChannel && channels.some(c => c._id === savedChannel)) {
setActiveChannel(savedChannel);
return;
}
}
const firstTextChannel = channels.find(c => c.type === 'text');
if (firstTextChannel) {
setActiveChannel(firstTextChannel._id);
}
}, [channels, activeChannel, userId]);
// Persist active channel
useEffect(() => {
if (activeChannel && userId) {
setUserPref(userId, 'lastActiveChannel', activeChannel, settings);
}
}, [activeChannel, userId]);
// Persist view mode
useEffect(() => {
if (userId) {
setUserPref(userId, 'lastView', view, settings);
}
}, [view, userId]);
const openDM = useCallback(async (targetUserId, targetUsername) => {
const uid = localStorage.getItem('userId');
if (!uid) return;
try {
const { channelId, created } = await convex.mutation(api.dms.openDM, {
userId: uid,
targetUserId
});
if (created) {
const keyHex = await crypto.randomBytes(32);
const allUsers = await convex.query(api.auth.getPublicKeys, {});
const participants = allUsers.filter(u => u.id === uid || u.id === targetUserId);
const batchKeys = [];
for (const u of participants) {
if (!u.public_identity_key) continue;
try {
const payload = JSON.stringify({ [channelId]: keyHex });
const encryptedKeyHex = await crypto.publicEncrypt(u.public_identity_key, payload);
batchKeys.push({
channelId,
userId: u.id,
encryptedKeyBundle: encryptedKeyHex,
keyVersion: 1
});
} catch (e) {
console.error('Failed to encrypt DM key for user', u.id, e);
}
}
if (batchKeys.length > 0) {
await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys });
}
}
setActiveDMChannel({ channel_id: channelId, other_username: targetUsername });
setView('me');
} catch (err) {
console.error('Error opening DM:', err);
}
}, [convex]);
const handleSelectChannel = useCallback((channelId) => {
setActiveChannel(channelId);
setShowPinned(false);
}, []);
const activeChannelObj = channels.find(c => c._id === activeChannel);
const { room, voiceStates, activeChannelId: voiceActiveChannelId, watchingStreamOf } = useVoice();
const isDMView = view === 'me' && activeDMChannel;
const isServerTextChannel = view === 'server' && activeChannel && activeChannelObj?.type !== 'voice';
const currentChannelId = isDMView ? activeDMChannel.channel_id : activeChannel;
// PiP: show when watching a stream and NOT currently viewing the voice channel in VoiceStage
const isViewingVoiceStage = view === 'server' && activeChannelObj?.type === 'voice' && activeChannel === voiceActiveChannelId;
const showPiP = watchingStreamOf !== null && !isViewingVoiceStage;
const handleGoBackToStream = useCallback(() => {
if (voiceActiveChannelId) {
setActiveChannel(voiceActiveChannelId);
setView('server');
}
}, [voiceActiveChannelId]);
function renderMainContent() {
if (view === 'me') {
if (activeDMChannel) {
return (
<div className="chat-container">
<ChatHeader
channelName={activeDMChannel.other_username}
channelType="dm"
onToggleMembers={() => {}}
showMembers={false}
onTogglePinned={() => setShowPinned(p => !p)}
/>
<div className="chat-content">
<ChatArea
channelId={activeDMChannel.channel_id}
channelName={activeDMChannel.other_username}
channelType="dm"
channelKey={channelKeys[activeDMChannel.channel_id]}
username={username}
userId={userId}
showMembers={false}
onToggleMembers={() => {}}
onOpenDM={openDM}
showPinned={showPinned}
onTogglePinned={() => setShowPinned(false)}
/>
</div>
</div>
);
}
return <FriendsView onOpenDM={openDM} />;
}
if (activeChannel) {
if (activeChannelObj?.type === 'voice') {
return <VoiceStage room={room} channelId={activeChannel} voiceStates={voiceStates} channelName={activeChannelObj?.name} />;
}
return (
<div className="chat-container">
<ChatHeader
channelName={activeChannelObj?.name || activeChannel}
channelType="text"
channelTopic={activeChannelObj?.topic}
onToggleMembers={() => setShowMembers(!showMembers)}
showMembers={showMembers}
onTogglePinned={() => setShowPinned(p => !p)}
serverName={serverName}
/>
<div className="chat-content">
<ChatArea
channelId={activeChannel}
channelName={activeChannelObj?.name || activeChannel}
channelType="text"
channelKey={channelKeys[activeChannel]}
username={username}
userId={userId}
showMembers={showMembers}
onToggleMembers={() => setShowMembers(!showMembers)}
onOpenDM={openDM}
showPinned={showPinned}
onTogglePinned={() => setShowPinned(false)}
/>
<MembersList
channelId={activeChannel}
visible={showMembers}
onMemberClick={(member) => {}}
/>
</div>
</div>
);
}
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#b9bbbe', flexDirection: 'column' }}>
<h2>Welcome to {serverName}</h2>
<p>No channels found.</p>
<p>Click the <b>+</b> in the sidebar to create your first encrypted channel.</p>
</div>
);
}
if (!userId) {
return (
<div className="app-container">
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#b9bbbe' }}>
Loading...
</div>
</div>
);
}
return (
<PresenceProvider userId={userId}>
<div className="app-container">
<Sidebar
channels={channels}
categories={categories}
activeChannel={activeChannel}
onSelectChannel={handleSelectChannel}
username={username}
channelKeys={channelKeys}
view={view}
onViewChange={setView}
onOpenDM={openDM}
activeDMChannel={activeDMChannel}
setActiveDMChannel={setActiveDMChannel}
dmChannels={dmChannels}
userId={userId}
serverName={serverName}
serverIconUrl={serverIconUrl}
/>
{renderMainContent()}
{showPiP && <FloatingStreamPiP onGoBackToStream={handleGoBackToStream} />}
<ToastContainer />
</div>
</PresenceProvider>
);
};
export default Chat;