feat: implement federation for inter-instance meeting invitations with dedicated API, UI, and configuration.
This commit is contained in:
168
src/pages/FederationInbox.jsx
Normal file
168
src/pages/FederationInbox.jsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Globe, Mail, Check, X, ExternalLink, Loader2, Inbox } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function FederationInbox() {
|
||||
const { t } = useLanguage();
|
||||
const [invitations, setInvitations] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchInvitations = async () => {
|
||||
try {
|
||||
const res = await api.get('/federation/invitations');
|
||||
setInvitations(res.data.invitations || []);
|
||||
} catch {
|
||||
toast.error(t('federation.loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvitations();
|
||||
}, []);
|
||||
|
||||
const handleAccept = async (id) => {
|
||||
try {
|
||||
const res = await api.post(`/federation/invitations/${id}/accept`);
|
||||
if (res.data.join_url) {
|
||||
window.open(res.data.join_url, '_blank');
|
||||
}
|
||||
toast.success(t('federation.accepted'));
|
||||
fetchInvitations();
|
||||
} catch {
|
||||
toast.error(t('federation.acceptFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecline = async (id) => {
|
||||
try {
|
||||
await api.delete(`/federation/invitations/${id}`);
|
||||
toast.success(t('federation.declined'));
|
||||
fetchInvitations();
|
||||
} catch {
|
||||
toast.error(t('federation.declineFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 size={32} className="animate-spin text-th-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pending = invitations.filter(i => i.status === 'pending');
|
||||
const past = invitations.filter(i => i.status !== 'pending');
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<Globe size={24} className="text-th-accent" />
|
||||
<h1 className="text-2xl font-bold text-th-text">{t('federation.inbox')}</h1>
|
||||
</div>
|
||||
<p className="text-sm text-th-text-s mt-1">{t('federation.inboxSubtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Pending invitations */}
|
||||
{pending.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-sm font-semibold text-th-text uppercase tracking-wider mb-4">
|
||||
{t('federation.pending')} ({pending.length})
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{pending.map(inv => (
|
||||
<div key={inv.id} className="card p-5 border-l-4 border-l-th-accent">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Mail size={16} className="text-th-accent flex-shrink-0" />
|
||||
<h3 className="text-base font-semibold text-th-text truncate">{inv.room_name}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-th-text-s">
|
||||
{t('federation.from')}: <span className="font-medium text-th-text">{inv.from_user}</span>
|
||||
</p>
|
||||
{inv.message && (
|
||||
<p className="text-sm text-th-text-s mt-2 italic">"{inv.message}"</p>
|
||||
)}
|
||||
<p className="text-xs text-th-text-s mt-1">
|
||||
{new Date(inv.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleAccept(inv.id)}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
<Check size={16} />
|
||||
{t('federation.accept')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDecline(inv.id)}
|
||||
className="btn-secondary text-sm"
|
||||
>
|
||||
<X size={16} />
|
||||
{t('federation.decline')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Past invitations */}
|
||||
{past.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-sm font-semibold text-th-text uppercase tracking-wider mb-4">
|
||||
{t('federation.previousInvites')}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{past.map(inv => (
|
||||
<div key={inv.id} className="card p-4 opacity-60">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium text-th-text truncate">{inv.room_name}</h3>
|
||||
<p className="text-xs text-th-text-s">{inv.from_user}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
|
||||
? 'bg-th-success/15 text-th-success'
|
||||
: 'bg-th-error/15 text-th-error'
|
||||
}`}>
|
||||
{inv.status === 'accepted' ? t('federation.statusAccepted') : t('federation.statusDeclined')}
|
||||
</span>
|
||||
{inv.status === 'accepted' && (
|
||||
<button
|
||||
onClick={() => window.open(inv.join_url, '_blank')}
|
||||
className="btn-ghost text-xs py-1.5 px-2"
|
||||
title={t('federation.openLink')}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{invitations.length === 0 && (
|
||||
<div className="card p-12 text-center">
|
||||
<Inbox size={48} className="mx-auto text-th-text-s/40 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-th-text mb-2">{t('federation.noInvitations')}</h3>
|
||||
<p className="text-sm text-th-text-s">{t('federation.noInvitationsSubtitle')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user