feat(calendar): implement local calendar invitations with email notifications
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m19s
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m19s
- Added functionality to create, accept, decline, and delete local calendar invitations. - Integrated email notifications for calendar event invitations and deletions. - Updated database schema to support local invitations and outbound event tracking. - Enhanced the calendar UI to display pending invitations and allow users to manage them. - Localized new strings for invitations in English and German.
This commit is contained in:
@@ -411,6 +411,8 @@
|
||||
"roomDeletedNotice": "Dieser Raum wurde vom Besitzer auf der Ursprungsinstanz gelöscht und ist nicht mehr verfügbar.",
|
||||
"calendarEvent": "Kalendereinladung",
|
||||
"calendarAccepted": "Kalender-Event angenommen und in deinen Kalender eingetragen!",
|
||||
"localCalendarEvent": "Lokale Kalendereinladung",
|
||||
"calendarLocalAccepted": "Einladung angenommen – Event wurde in deinen Kalender eingetragen!",
|
||||
"invitationRemoved": "Einladung entfernt",
|
||||
"removeInvitation": "Einladung entfernen"
|
||||
},
|
||||
@@ -458,6 +460,11 @@
|
||||
"shareAdded": "Benutzer zum Event hinzugefügt",
|
||||
"shareRemoved": "Freigabe entfernt",
|
||||
"shareFailed": "Event konnte nicht geteilt werden",
|
||||
"invitationSent": "Einladung gesendet!",
|
||||
"invitationCancelled": "Einladung widerrufen",
|
||||
"invitationPending": "Einladung ausstehend",
|
||||
"pendingInvitations": "Ausstehende Einladungen",
|
||||
"accepted": "Angenommen",
|
||||
"sendFederated": "An Remote senden",
|
||||
"sendFederatedTitle": "Event an Remote-Instanz senden",
|
||||
"sendFederatedDesc": "Sende dieses Kalender-Event an einen Benutzer auf einer anderen Redlight-Instanz. Der Empfänger muss die Einladung zuerst annehmen, bevor das Event in seinem Kalender erscheint.",
|
||||
|
||||
@@ -411,6 +411,8 @@
|
||||
"roomDeletedNotice": "This room has been deleted by the owner on the origin instance and is no longer available.",
|
||||
"calendarEvent": "Calendar Invitation",
|
||||
"calendarAccepted": "Calendar event accepted and added to your calendar!",
|
||||
"localCalendarEvent": "Local Calendar Invitation",
|
||||
"calendarLocalAccepted": "Invitation accepted – event added to your calendar!",
|
||||
"invitationRemoved": "Invitation removed",
|
||||
"removeInvitation": "Remove invitation"
|
||||
},
|
||||
@@ -458,6 +460,11 @@
|
||||
"shareAdded": "User added to event",
|
||||
"shareRemoved": "Share removed",
|
||||
"shareFailed": "Could not share event",
|
||||
"invitationSent": "Invitation sent!",
|
||||
"invitationCancelled": "Invitation cancelled",
|
||||
"invitationPending": "Invitation pending",
|
||||
"pendingInvitations": "Pending Invitations",
|
||||
"accepted": "Accepted",
|
||||
"sendFederated": "Send to remote",
|
||||
"sendFederatedTitle": "Send Event to Remote Instance",
|
||||
"sendFederatedDesc": "Send this calendar event to a user on another Redlight instance. The recipient must accept the invitation before the event appears in their calendar.",
|
||||
|
||||
@@ -39,6 +39,7 @@ export default function Calendar() {
|
||||
const [shareSearch, setShareSearch] = useState('');
|
||||
const [shareResults, setShareResults] = useState([]);
|
||||
const [sharedUsers, setSharedUsers] = useState([]);
|
||||
const [pendingInvitations, setPendingInvitations] = useState([]);
|
||||
const [fedAddress, setFedAddress] = useState('');
|
||||
const [fedSending, setFedSending] = useState(false);
|
||||
|
||||
@@ -269,9 +270,11 @@ export default function Calendar() {
|
||||
setShowShare(ev);
|
||||
setShareSearch('');
|
||||
setShareResults([]);
|
||||
setPendingInvitations([]);
|
||||
try {
|
||||
const res = await api.get(`/calendar/events/${ev.id}`);
|
||||
setSharedUsers(res.data.sharedUsers || []);
|
||||
setPendingInvitations(res.data.pendingInvitations || []);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
@@ -281,7 +284,8 @@ export default function Calendar() {
|
||||
try {
|
||||
const res = await api.get(`/rooms/users/search?q=${encodeURIComponent(query)}`);
|
||||
const sharedIds = new Set(sharedUsers.map(u => u.id));
|
||||
setShareResults(res.data.users.filter(u => !sharedIds.has(u.id)));
|
||||
const pendingIds = new Set(pendingInvitations.map(u => u.user_id));
|
||||
setShareResults(res.data.users.filter(u => !sharedIds.has(u.id) && !pendingIds.has(u.id)));
|
||||
} catch { setShareResults([]); }
|
||||
};
|
||||
|
||||
@@ -290,9 +294,10 @@ export default function Calendar() {
|
||||
try {
|
||||
const res = await api.post(`/calendar/events/${showShare.id}/share`, { user_id: userId });
|
||||
setSharedUsers(res.data.sharedUsers);
|
||||
setPendingInvitations(res.data.pendingInvitations || []);
|
||||
setShareSearch('');
|
||||
setShareResults([]);
|
||||
toast.success(t('calendar.shareAdded'));
|
||||
toast.success(t('calendar.invitationSent'));
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('calendar.shareFailed'));
|
||||
}
|
||||
@@ -303,10 +308,21 @@ export default function Calendar() {
|
||||
try {
|
||||
const res = await api.delete(`/calendar/events/${showShare.id}/share/${userId}`);
|
||||
setSharedUsers(res.data.sharedUsers);
|
||||
setPendingInvitations(res.data.pendingInvitations || []);
|
||||
toast.success(t('calendar.shareRemoved'));
|
||||
} catch { toast.error(t('calendar.shareFailed')); }
|
||||
};
|
||||
|
||||
const handleCancelInvitation = async (userId) => {
|
||||
if (!showShare) return;
|
||||
try {
|
||||
const res = await api.delete(`/calendar/events/${showShare.id}/share/${userId}`);
|
||||
setSharedUsers(res.data.sharedUsers);
|
||||
setPendingInvitations(res.data.pendingInvitations || []);
|
||||
toast.success(t('calendar.invitationCancelled'));
|
||||
} catch { toast.error(t('calendar.shareFailed')); }
|
||||
};
|
||||
|
||||
const handleFedSend = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!showFedShare) return;
|
||||
@@ -721,8 +737,36 @@ export default function Calendar() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pendingInvitations.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-th-text-s uppercase tracking-wider">{t('calendar.pendingInvitations')}</p>
|
||||
{pendingInvitations.map(u => (
|
||||
<div key={u.user_id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border border-dashed">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
|
||||
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
||||
>
|
||||
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-th-text truncate">{u.display_name || u.name}</div>
|
||||
<div className="text-xs text-th-warning">{t('calendar.invitationPending')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => handleCancelInvitation(u.user_id)} className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sharedUsers.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{pendingInvitations.length > 0 && (
|
||||
<p className="text-xs font-semibold text-th-text-s uppercase tracking-wider">{t('calendar.accepted')}</p>
|
||||
)}
|
||||
{sharedUsers.map(u => (
|
||||
<div key={u.id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
|
||||
@@ -8,16 +8,19 @@ export default function FederationInbox() {
|
||||
const { t } = useLanguage();
|
||||
const [invitations, setInvitations] = useState([]);
|
||||
const [calendarInvitations, setCalendarInvitations] = useState([]);
|
||||
const [localCalInvitations, setLocalCalInvitations] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchInvitations = async () => {
|
||||
try {
|
||||
const [roomRes, calRes] = await Promise.all([
|
||||
const [roomRes, calRes, localCalRes] = await Promise.all([
|
||||
api.get('/federation/invitations'),
|
||||
api.get('/federation/calendar-invitations').catch(() => ({ data: { invitations: [] } })),
|
||||
api.get('/calendar/local-invitations').catch(() => ({ data: { invitations: [] } })),
|
||||
]);
|
||||
setInvitations(roomRes.data.invitations || []);
|
||||
setCalendarInvitations(calRes.data.invitations || []);
|
||||
setLocalCalInvitations(localCalRes.data.invitations || []);
|
||||
} catch {
|
||||
toast.error(t('federation.loadFailed'));
|
||||
} finally {
|
||||
@@ -91,6 +94,37 @@ export default function FederationInbox() {
|
||||
}
|
||||
};
|
||||
|
||||
// ── Local calendar invitation actions ───────────────────────────────────
|
||||
const handleLocalCalAccept = async (id) => {
|
||||
try {
|
||||
await api.post(`/calendar/local-invitations/${id}/accept`);
|
||||
toast.success(t('federation.calendarLocalAccepted'));
|
||||
fetchInvitations();
|
||||
} catch {
|
||||
toast.error(t('federation.acceptFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocalCalDecline = async (id) => {
|
||||
try {
|
||||
await api.delete(`/calendar/local-invitations/${id}`);
|
||||
toast.success(t('federation.declined'));
|
||||
fetchInvitations();
|
||||
} catch {
|
||||
toast.error(t('federation.declineFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocalCalDelete = async (id) => {
|
||||
try {
|
||||
await api.delete(`/calendar/local-invitations/${id}`);
|
||||
toast.success(t('federation.invitationRemoved'));
|
||||
fetchInvitations();
|
||||
} catch {
|
||||
toast.error(t('federation.declineFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
@@ -103,9 +137,11 @@ export default function FederationInbox() {
|
||||
const pastRooms = invitations.filter(i => i.status !== 'pending');
|
||||
const pendingCal = calendarInvitations.filter(i => i.status === 'pending');
|
||||
const pastCal = calendarInvitations.filter(i => i.status !== 'pending');
|
||||
const pendingLocalCal = localCalInvitations.filter(i => i.status === 'pending');
|
||||
const pastLocalCal = localCalInvitations.filter(i => i.status !== 'pending');
|
||||
|
||||
const totalPending = pendingRooms.length + pendingCal.length;
|
||||
const totalPast = pastRooms.length + pastCal.length;
|
||||
const totalPending = pendingRooms.length + pendingCal.length + pendingLocalCal.length;
|
||||
const totalPast = pastRooms.length + pastCal.length + pastLocalCal.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -196,6 +232,45 @@ export default function FederationInbox() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Pending local calendar invitations */}
|
||||
{pendingLocalCal.map(inv => (
|
||||
<div key={`localcal-${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">
|
||||
<Calendar size={16} className="text-th-accent flex-shrink-0" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-th-accent mr-1">
|
||||
{t('federation.localCalendarEvent')}
|
||||
</span>
|
||||
<h3 className="text-base font-semibold text-th-text truncate">{inv.title}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-th-text-s">
|
||||
{t('federation.from')}: <span className="font-medium text-th-text">{inv.from_name}</span>
|
||||
</p>
|
||||
<p className="text-sm text-th-text-s mt-1">
|
||||
{new Date(inv.start_time).toLocaleString()} – {new Date(inv.end_time).toLocaleString()}
|
||||
</p>
|
||||
{inv.description && (
|
||||
<p className="text-sm text-th-text-s mt-1 italic">"{inv.description}"</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={() => handleLocalCalAccept(inv.id)} className="btn-primary text-sm">
|
||||
<Check size={16} />
|
||||
{t('federation.accept')}
|
||||
</button>
|
||||
<button onClick={() => handleLocalCalDecline(inv.id)} className="btn-secondary text-sm">
|
||||
<X size={16} />
|
||||
{t('federation.decline')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -284,6 +359,36 @@ export default function FederationInbox() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Past local calendar invitations */}
|
||||
{pastLocalCal.map(inv => (
|
||||
<div key={`localcal-past-${inv.id}`} className="card p-4 opacity-70">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex items-center gap-2">
|
||||
<Calendar size={14} className="text-th-text-s flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium text-th-text truncate">{inv.title}</h3>
|
||||
<p className="text-xs text-th-text-s">{inv.from_name} · {new Date(inv.start_time).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 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>
|
||||
<button
|
||||
onClick={() => handleLocalCalDelete(inv.id)}
|
||||
className="btn-ghost text-xs py-1.5 px-2 text-th-text-s hover:text-th-error"
|
||||
title={t('federation.removeInvitation')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user