add now features

This commit is contained in:
Matthias Hochmeister
2026-03-01 11:50:27 +01:00
parent 73ab6cea07
commit 681acd8203
25 changed files with 1518 additions and 4 deletions

View File

@@ -0,0 +1,281 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import {
Card,
CardContent,
Typography,
Box,
Chip,
Divider,
Skeleton,
Button,
CircularProgress,
IconButton,
Tooltip,
} from '@mui/material';
import { Forum, Cloud, LinkOff } from '@mui/icons-material';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { formatDistanceToNow } from 'date-fns';
import { de } from 'date-fns/locale';
import { nextcloudApi } from '../../services/nextcloud';
import type { NextcloudConversation } from '../../types/nextcloud.types';
const POLL_INTERVAL = 2000;
const POLL_TIMEOUT = 5 * 60 * 1000;
const ConversationRow: React.FC<{ conversation: NextcloudConversation; showDivider: boolean }> = ({
conversation,
showDivider,
}) => {
const handleClick = () => {
window.open(conversation.url, '_blank', 'noopener,noreferrer');
};
const relativeTime = conversation.lastMessage
? formatDistanceToNow(new Date(conversation.lastMessage.timestamp * 1000), {
addSuffix: true,
locale: de,
})
: null;
return (
<>
<Box
onClick={handleClick}
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
py: 1.5,
px: 1,
cursor: 'pointer',
borderRadius: 1,
transition: 'background-color 0.15s ease',
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2" noWrap>
{conversation.displayName}
</Typography>
{conversation.unreadMessages > 0 && (
<Chip
label={conversation.unreadMessages}
size="small"
color="primary"
sx={{ height: 20, fontSize: '0.7rem', minWidth: 24 }}
/>
)}
</Box>
{conversation.lastMessage && (
<Typography
variant="body2"
color="text.secondary"
noWrap
sx={{ mt: 0.25 }}
>
{conversation.lastMessage.author}: {conversation.lastMessage.text}
</Typography>
)}
</Box>
{relativeTime && (
<Typography
variant="caption"
color="text.secondary"
sx={{ ml: 1, whiteSpace: 'nowrap', mt: 0.25 }}
>
{relativeTime}
</Typography>
)}
</Box>
{showDivider && <Divider />}
</>
);
};
const NextcloudTalkWidget: React.FC = () => {
const queryClient = useQueryClient();
const [isConnecting, setIsConnecting] = useState(false);
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const popupRef = useRef<Window | null>(null);
const { data, isLoading, isError } = useQuery({
queryKey: ['nextcloud-talk'],
queryFn: () => nextcloudApi.getConversations(),
refetchInterval: 30000,
retry: 1,
});
const connected = data?.connected ?? false;
const conversations = data?.conversations?.slice(0, 5) ?? [];
const totalUnread = data?.totalUnread ?? 0;
const stopPolling = useCallback(() => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
if (popupRef.current && !popupRef.current.closed) {
popupRef.current.close();
}
popupRef.current = null;
setIsConnecting(false);
}, []);
useEffect(() => {
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
if (popupRef.current && !popupRef.current.closed) {
popupRef.current.close();
}
};
}, []);
const handleConnect = async () => {
try {
setIsConnecting(true);
const { loginUrl, pollToken, pollEndpoint } = await nextcloudApi.connect();
const popup = window.open(loginUrl, '_blank', 'width=600,height=700');
popupRef.current = popup;
const startTime = Date.now();
pollIntervalRef.current = setInterval(async () => {
if (Date.now() - startTime > POLL_TIMEOUT) {
stopPolling();
return;
}
if (popup && popup.closed) {
stopPolling();
return;
}
try {
const result = await nextcloudApi.poll(pollToken, pollEndpoint);
if (result.completed) {
stopPolling();
queryClient.invalidateQueries({ queryKey: ['nextcloud-talk'] });
}
} catch {
// Polling error — keep trying until timeout
}
}, POLL_INTERVAL);
} catch {
setIsConnecting(false);
}
};
const handleDisconnect = async () => {
try {
await nextcloudApi.disconnect();
queryClient.invalidateQueries({ queryKey: ['nextcloud-talk'] });
} catch {
// Disconnect failed silently
}
};
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': {
boxShadow: 3,
},
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Forum color="primary" />
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Nextcloud Talk
</Typography>
{connected && totalUnread > 0 && (
<Chip
label={`${totalUnread} ungelesen`}
size="small"
color="primary"
variant="outlined"
/>
)}
{connected && (
<Tooltip title="Verbindung trennen">
<IconButton size="small" onClick={handleDisconnect}>
<LinkOff fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
{isLoading && (
<Box>
{[1, 2, 3].map((n) => (
<Box key={n} sx={{ mb: 1.5 }}>
<Skeleton variant="text" width="60%" height={22} />
<Skeleton variant="text" width="90%" height={18} />
</Box>
))}
</Box>
)}
{isError && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Nextcloud nicht erreichbar
</Typography>
)}
{!isLoading && !isError && !connected && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 3, gap: 2 }}>
{isConnecting ? (
<>
<CircularProgress size={32} />
<Typography variant="body2" color="text.secondary">
Warte auf Bestätigung...
</Typography>
</>
) : (
<>
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center' }}>
Verbinde dein Nextcloud-Konto, um Chats zu sehen.
</Typography>
<Button
variant="outlined"
startIcon={<Cloud />}
onClick={handleConnect}
>
Mit Nextcloud verbinden
</Button>
</>
)}
</Box>
)}
{!isLoading && !isError && connected && conversations.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Keine aktiven Chats
</Typography>
)}
{!isLoading && !isError && connected && conversations.length > 0 && (
<Box>
{conversations.map((conversation, index) => (
<ConversationRow
key={conversation.token}
conversation={conversation}
showDivider={index < conversations.length - 1}
/>
))}
</Box>
)}
</CardContent>
</Card>
);
};
export default NextcloudTalkWidget;