fix(mobile): resolve all TypeScript errors from integration

- Fix voiceStore method names (toggleMic, leaveVoice, setCurrentVoiceChannel)
- Fix MemberWithUser nested user property access
- Pass required channelId props to MessageList, TypingIndicator, MessageInput
- Fix ConfirmDialog props (isOpen, description, variant, onClose)
- Add mobileStyle prop to Modal component (fullscreen + sheet modes)
- Fix fromId/isAdmin camelCase property names
- Guard possibly undefined touch events and array accesses
- Fix notification channelId resolution
This commit is contained in:
Jannis Braun
2026-03-17 15:54:39 +01:00
parent 7619b091da
commit ed70099963
11 changed files with 89 additions and 25 deletions
@@ -62,8 +62,7 @@ export function NotificationController() {
? message.content.replace(/[*_~`>#\-\[\]]/g, '').slice(0, 100) ? message.content.replace(/[*_~`>#\-\[\]]/g, '').slice(0, 100)
: 'Sent an attachment'; : 'Sent an attachment';
sendNotification(displayName, body, { sendNotification(displayName, body, {
channelId: message.channelId ?? message.dmChannelId, channelId: message.channelId,
spaceId: message.dmChannelId ? '@me' : undefined,
}); });
break; // one notification per batch break; // one notification per batch
} }
@@ -35,7 +35,7 @@ export function MobileBottomNav() {
const hasUnreadSpaces = Array.from(unreadChannels).some(chId => !voiceChannelIds.has(chId)); const hasUnreadSpaces = Array.from(unreadChannels).some(chId => !voiceChannelIds.has(chId));
// Pending friend requests — filter for incoming only // Pending friend requests — filter for incoming only
const pendingIncoming = requests.filter(r => r.status === 'pending' && r.from_id !== authUser?.id); const pendingIncoming = requests.filter(r => r.status === 'pending' && r.fromId !== authUser?.id);
const handleTab = (tab: 'spaces' | 'dms' | 'you') => { const handleTab = (tab: 'spaces' | 'dms' | 'you') => {
setMobileTab(tab); setMobileTab(tab);
@@ -75,12 +75,12 @@ export function MobileChatScreen({ params }: MobileChatScreenProps) {
{/* Messages */} {/* Messages */}
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<MessageList /> {channelId && <MessageList channelId={channelId} />}
</div> </div>
{/* Typing indicator + Input */} {/* Typing indicator + Input */}
<TypingIndicator /> {channelId && <TypingIndicator channelId={channelId} />}
<MessageInput /> {channelId && <MessageInput channelId={channelId} channelName={channelName} />}
</div> </div>
); );
} }
@@ -50,7 +50,7 @@ const sectionIcons: Record<string, React.ReactNode> = {
export function MobileSettingsScreen({ initialPanel }: MobileSettingsScreenProps) { export function MobileSettingsScreen({ initialPanel }: MobileSettingsScreenProps) {
const popMobileScreen = useUIStore((s) => s.popMobileScreen); const popMobileScreen = useUIStore((s) => s.popMobileScreen);
const pushMobileScreen = useUIStore((s) => s.pushMobileScreen); const pushMobileScreen = useUIStore((s) => s.pushMobileScreen);
const isAdmin = useAuthStore((s) => s.user?.is_admin); const isAdmin = useAuthStore((s) => s.user?.isAdmin);
// If initialPanel is set, render that panel directly // If initialPanel is set, render that panel directly
if (initialPanel) { if (initialPanel) {
@@ -63,7 +63,8 @@ export function MobileShell() {
const path = location.pathname; const path = location.pathname;
const match = path.match(/^\/channels\/([^/]+)\/([^/]+)$/); const match = path.match(/^\/channels\/([^/]+)\/([^/]+)$/);
if (match && mobileStack.length === 0) { if (match && mobileStack.length === 0) {
const [, spaceId, channelId] = match; const spaceId = match[1] ?? '';
const channelId = match[2] ?? '';
if (spaceId === '@me') { if (spaceId === '@me') {
pushMobileScreen('channel-chat', { channelId, spaceId: '@me' }); pushMobileScreen('channel-chat', { channelId, spaceId: '@me' });
} else { } else {
@@ -27,7 +27,7 @@ export function MobileSpacesScreen() {
const voiceUsers = useVoiceStore((s) => s.voiceUsers); const voiceUsers = useVoiceStore((s) => s.voiceUsers);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const joinChannel = useVoiceStore((s) => s.joinChannel); const setCurrentVoiceChannel = useVoiceStore((s) => s.setCurrentVoiceChannel);
const pushMobileScreen = useUIStore((s) => s.pushMobileScreen); const pushMobileScreen = useUIStore((s) => s.pushMobileScreen);
const setMobileTab = useUIStore((s) => s.setMobileTab); const setMobileTab = useUIStore((s) => s.setMobileTab);
@@ -52,7 +52,7 @@ export function MobileSpacesScreen() {
// Auto-select first space if none selected // Auto-select first space if none selected
useEffect(() => { useEffect(() => {
if (!selectedSpaceId && spaces.length > 0) { if (!selectedSpaceId && spaces.length > 0 && spaces[0]) {
setSelectedSpaceId(spaces[0].id); setSelectedSpaceId(spaces[0].id);
} }
}, [selectedSpaceId, spaces]); }, [selectedSpaceId, spaces]);
@@ -107,7 +107,7 @@ export function MobileSpacesScreen() {
const handleChannelTap = (channel: Channel) => { const handleChannelTap = (channel: Channel) => {
if (channel.type === 'voice') { if (channel.type === 'voice') {
joinChannel(channel.id); setCurrentVoiceChannel(channel.id);
return; return;
} }
if (selectedSpaceId) { if (selectedSpaceId) {
@@ -13,11 +13,11 @@ export function MobileVoiceFullScreen() {
const isDeafened = useVoiceStore((s) => s.isDeafened); const isDeafened = useVoiceStore((s) => s.isDeafened);
const isCameraOn = useVoiceStore((s) => s.isCameraOn); const isCameraOn = useVoiceStore((s) => s.isCameraOn);
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing); const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const toggleMute = useVoiceStore((s) => s.toggleMute); const toggleMute = useVoiceStore((s) => s.toggleMic);
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen); const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
const toggleCamera = useVoiceStore((s) => s.toggleCamera); const toggleCamera = useVoiceStore((s) => s.toggleCamera);
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare); const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
const leaveChannel = useVoiceStore((s) => s.leaveChannel); const leaveVoice = useVoiceStore((s) => s.leaveVoice);
const voiceUsers = useVoiceStore((s) => s.voiceUsers); const voiceUsers = useVoiceStore((s) => s.voiceUsers);
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates); const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
@@ -62,9 +62,9 @@ export function MobileVoiceFullScreen() {
const member = members.find(m => m.userId === userId); const member = members.find(m => m.userId === userId);
if (member) { if (member) {
return { return {
name: member.nickname ?? member.displayName ?? member.username, name: member.nickname ?? member.user?.displayName ?? member.user?.username ?? 'User',
avatar: member.avatar ? `/api/uploads/${member.avatar}` : null, avatar: member.user?.avatar ? `/api/uploads/${member.user.avatar}` : null,
avatarColor: member.avatarColor, avatarColor: member.user?.avatarColor ?? null,
}; };
} }
// Check DM members // Check DM members
@@ -82,7 +82,7 @@ export function MobileVoiceFullScreen() {
}; };
const handleDisconnect = () => { const handleDisconnect = () => {
leaveChannel(); leaveVoice();
popMobileScreen(); popMobileScreen();
}; };
@@ -10,10 +10,10 @@ export function MobileVoiceMiniBar() {
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const isMuted = useVoiceStore((s) => s.isMuted); const isMuted = useVoiceStore((s) => s.isMuted);
const isDeafened = useVoiceStore((s) => s.isDeafened); const isDeafened = useVoiceStore((s) => s.isDeafened);
const toggleMute = useVoiceStore((s) => s.toggleMute); const toggleMute = useVoiceStore((s) => s.toggleMic);
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen); const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
const voiceUsers = useVoiceStore((s) => s.voiceUsers); const voiceUsers = useVoiceStore((s) => s.voiceUsers);
const leaveChannel = useVoiceStore((s) => s.leaveChannel); const leaveVoice = useVoiceStore((s) => s.leaveVoice);
const channels = useSpaceStore((s) => s.channels); const channels = useSpaceStore((s) => s.channels);
const dmChannels = useSpaceStore((s) => s.dmChannels); const dmChannels = useSpaceStore((s) => s.dmChannels);
@@ -21,7 +21,8 @@ export function MobileVoiceMiniBar() {
if (!currentVoiceChannelId) return null; if (!currentVoiceChannelId) return null;
// Don't show mini-bar if voice full-screen is on top of the stack // Don't show mini-bar if voice full-screen is on top of the stack
const topScreen = mobileStack.length > 0 ? mobileStack[mobileStack.length - 1].screen : null; const topEntry = mobileStack.length > 0 ? mobileStack[mobileStack.length - 1] : undefined;
const topScreen = topEntry?.screen ?? null;
if (topScreen === 'voice-full') return null; if (topScreen === 'voice-full') return null;
// Resolve channel name // Resolve channel name
@@ -91,7 +92,7 @@ export function MobileVoiceMiniBar() {
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); leaveChannel(); }} onClick={(e) => { e.stopPropagation(); leaveVoice(); }}
className="w-8 h-8 rounded-full flex items-center justify-center bg-accent-rose/20 text-accent-rose hover:bg-accent-rose/30 transition-colors" className="w-8 h-8 rounded-full flex items-center justify-center bg-accent-rose/20 text-accent-rose hover:bg-accent-rose/30 transition-colors"
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -142,12 +142,13 @@ export function MobileYouScreen() {
{showLogoutConfirm && ( {showLogoutConfirm && (
<ConfirmDialog <ConfirmDialog
isOpen={true}
title="Log Out" title="Log Out"
message="Are you sure you want to log out?" description="Are you sure you want to log out?"
confirmLabel="Log Out" confirmLabel="Log Out"
onConfirm={() => { setShowLogoutConfirm(false); logout(); }} onConfirm={() => { setShowLogoutConfirm(false); logout(); }}
onCancel={() => setShowLogoutConfirm(false)} onClose={() => setShowLogoutConfirm(false)}
destructive variant="danger"
/> />
)} )}
</div> </div>
+61 -1
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useCallback } from 'react'; import React, { useEffect, useCallback } from 'react';
import { useUIStore } from '../../stores/uiStore';
interface ModalProps { interface ModalProps {
isOpen: boolean; isOpen: boolean;
@@ -6,9 +7,13 @@ interface ModalProps {
title?: string; title?: string;
children: React.ReactNode; children: React.ReactNode;
maxWidth?: string; maxWidth?: string;
/** Mobile display style: 'fullscreen' fills the screen, 'sheet' anchors to bottom, 'default' stays centered */
mobileStyle?: 'fullscreen' | 'sheet' | 'default';
} }
export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md' }: ModalProps) { export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md', mobileStyle = 'default' }: ModalProps) {
const isMobile = useUIStore((s) => s.isMobile);
const handleKeyDown = useCallback((e: KeyboardEvent) => { const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
onClose(); onClose();
@@ -24,6 +29,61 @@ export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md'
if (!isOpen) return null; if (!isOpen) return null;
// Mobile fullscreen style
if (isMobile && mobileStyle === 'fullscreen') {
return (
<div className="fixed inset-0 z-[200] flex flex-col bg-surface-base animate-fade-in">
{title && (
<div className="flex items-center justify-between px-4 pt-4 flex-shrink-0" style={{ paddingTop: 'calc(16px + env(safe-area-inset-top))' }}>
<h2 className="text-xl font-bold text-txt-primary">{title}</h2>
<button
onClick={onClose}
className="text-txt-tertiary hover:text-txt-primary transition-colors p-1"
>
<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>
)}
<div className="p-4 overflow-y-auto scrollbar-thin flex-1 min-h-0" style={{ paddingBottom: 'calc(16px + env(safe-area-inset-bottom))' }}>
{children}
</div>
</div>
);
}
// Mobile bottom sheet style
if (isMobile && mobileStyle === 'sheet') {
return (
<div className="fixed inset-0 z-[200] flex items-end justify-center animate-fade-in">
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
/>
<div className="relative w-full max-h-[85vh] flex flex-col glass-modal rounded-t-2xl animate-slide-up" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
{title && (
<div className="flex items-center justify-between px-4 pt-4 flex-shrink-0">
<h2 className="text-xl font-bold text-txt-primary">{title}</h2>
<button
onClick={onClose}
className="text-txt-tertiary hover:text-txt-primary transition-colors p-1"
>
<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>
)}
<div className="p-4 overflow-y-auto scrollbar-thin flex-1 min-h-0">
{children}
</div>
</div>
</div>
);
}
// Default centered dialog (desktop and mobile default)
return ( return (
<div className="fixed inset-0 z-[200] flex items-center justify-center animate-fade-in"> <div className="fixed inset-0 z-[200] flex items-center justify-center animate-fade-in">
<div <div
@@ -19,6 +19,7 @@ export function useSwipeGesture({
const handleTouchStart = useCallback((e: TouchEvent) => { const handleTouchStart = useCallback((e: TouchEvent) => {
if (!enabled) return; if (!enabled) return;
const touch = e.touches[0]; const touch = e.touches[0];
if (!touch) return;
if (touch.clientX <= edgeThreshold) { if (touch.clientX <= edgeThreshold) {
touchStartRef.current = { x: touch.clientX, y: touch.clientY }; touchStartRef.current = { x: touch.clientX, y: touch.clientY };
swipingRef.current = false; swipingRef.current = false;
@@ -28,6 +29,7 @@ export function useSwipeGesture({
const handleTouchMove = useCallback((e: TouchEvent) => { const handleTouchMove = useCallback((e: TouchEvent) => {
if (!touchStartRef.current) return; if (!touchStartRef.current) return;
const touch = e.touches[0]; const touch = e.touches[0];
if (!touch) return;
const dx = touch.clientX - touchStartRef.current.x; const dx = touch.clientX - touchStartRef.current.x;
const dy = touch.clientY - touchStartRef.current.y; const dy = touch.clientY - touchStartRef.current.y;