refactor recurring event modification (#173)
This commit is contained in:
@@ -14,10 +14,13 @@ import { getOpenPaasUser, getUserDetails } from "../User/userAPI";
|
||||
import { parseCalendarEvent } from "../Events/eventUtils";
|
||||
import {
|
||||
deleteEvent,
|
||||
deleteEventInstance,
|
||||
getEvent,
|
||||
importEventFromFile,
|
||||
moveEvent,
|
||||
putEvent,
|
||||
putEventWithOverrides,
|
||||
updateSeries,
|
||||
} from "../Events/EventApi";
|
||||
import {
|
||||
computeWeekRange,
|
||||
@@ -274,6 +277,29 @@ export const deleteEventAsync = createAsyncThunk<
|
||||
return { calId, eventId };
|
||||
});
|
||||
|
||||
export const deleteEventInstanceAsync = createAsyncThunk<
|
||||
{ calId: string; eventId: string },
|
||||
{ cal: Calendars; event: CalendarEvent }
|
||||
>("calendars/delEventInstance", async ({ cal, event }) => {
|
||||
await deleteEventInstance(event, cal.ownerEmails?.[0]);
|
||||
return { calId: cal.id, eventId: event.uid };
|
||||
});
|
||||
|
||||
export const updateEventInstanceAsync = createAsyncThunk<
|
||||
{ calId: string; event: CalendarEvent },
|
||||
{ cal: Calendars; event: CalendarEvent }
|
||||
>("calendars/updateEventInstance", async ({ cal, event }) => {
|
||||
await putEventWithOverrides(event, cal.ownerEmails?.[0]);
|
||||
return { calId: cal.id, event };
|
||||
});
|
||||
|
||||
export const updateSeriesAsync = createAsyncThunk<
|
||||
void,
|
||||
{ cal: Calendars; event: CalendarEvent }
|
||||
>("calendars/updateSeries", async ({ cal, event }) => {
|
||||
await updateSeries(event, cal.ownerEmails?.[0]);
|
||||
});
|
||||
|
||||
export const createCalendarAsync = createAsyncThunk<
|
||||
{
|
||||
userId: string;
|
||||
@@ -545,6 +571,18 @@ const CalendarSlice = createSlice({
|
||||
];
|
||||
}
|
||||
})
|
||||
.addCase(deleteEventInstanceAsync.fulfilled, (state, action) => {
|
||||
state.pending = false;
|
||||
delete state.list[action.payload.calId].events[action.payload.eventId];
|
||||
})
|
||||
.addCase(updateEventInstanceAsync.fulfilled, (state, action) => {
|
||||
state.pending = false;
|
||||
state.list[action.payload.calId].events[action.payload.event.uid] =
|
||||
action.payload.event;
|
||||
})
|
||||
.addCase(updateSeriesAsync.fulfilled, (state) => {
|
||||
state.pending = false;
|
||||
})
|
||||
.addCase(createCalendarAsync.fulfilled, (state, action) => {
|
||||
state.pending = false;
|
||||
state.list[`${action.payload.userId}/${action.payload.calId}`] = {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { api } from "../../utils/apiUtils";
|
||||
import { TIMEZONES } from "../../utils/timezone-data";
|
||||
import { CalendarEvent } from "./EventsTypes";
|
||||
import { calendarEventToJCal, parseCalendarEvent } from "./eventUtils";
|
||||
import {
|
||||
calendarEventToJCal,
|
||||
makeTimezone,
|
||||
makeVevent,
|
||||
parseCalendarEvent,
|
||||
} from "./eventUtils";
|
||||
import ICAL from "ical.js";
|
||||
|
||||
export async function getEvent(event: CalendarEvent) {
|
||||
export async function getEvent(event: CalendarEvent, isMaster?: boolean) {
|
||||
const response = await api.get(`dav${event.URL}`);
|
||||
const eventData = await response.text();
|
||||
const eventical = ICAL.parse(eventData);
|
||||
@@ -13,6 +19,9 @@ export async function getEvent(event: CalendarEvent) {
|
||||
event.calId,
|
||||
event.URL
|
||||
);
|
||||
if (isMaster) {
|
||||
return { ...event, ...eventjson };
|
||||
}
|
||||
return { ...eventjson, ...event };
|
||||
}
|
||||
|
||||
@@ -37,6 +46,107 @@ export async function putEvent(event: CalendarEvent, calOwnerEmail?: string) {
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function putEventWithOverrides(
|
||||
updatedEvent: CalendarEvent,
|
||||
calOwnerEmail?: string
|
||||
) {
|
||||
const vevents = await getAllRecurrentEvent(updatedEvent);
|
||||
|
||||
const updatedVevent = makeVevent(
|
||||
updatedEvent,
|
||||
updatedEvent.timezone,
|
||||
calOwnerEmail,
|
||||
!updatedEvent.recurrenceId
|
||||
);
|
||||
let replaced = false;
|
||||
for (let i = 0; i < vevents.length; i++) {
|
||||
const ve = vevents[i];
|
||||
const recurrenceId = ve[1].find(([k]: string[]) => k === "recurrence-id");
|
||||
if (recurrenceId && recurrenceId[3] === updatedEvent.recurrenceId) {
|
||||
vevents[i] = updatedVevent; // replace
|
||||
replaced = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!replaced && updatedEvent.recurrenceId) {
|
||||
vevents.push(updatedVevent); // add new override
|
||||
}
|
||||
|
||||
const timezoneData = TIMEZONES.zones[updatedEvent.timezone];
|
||||
const vtimezone = makeTimezone(timezoneData, updatedEvent);
|
||||
|
||||
const newJCal = ["vcalendar", [], [...vevents, vtimezone.component.jCal]];
|
||||
|
||||
return api(`dav${updatedEvent.URL}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(newJCal),
|
||||
headers: {
|
||||
"content-type": "text/calendar; charset=utf-8",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const deleteEventInstance = async (
|
||||
event: CalendarEvent,
|
||||
calOwnerEmail?: string
|
||||
) => {
|
||||
const seriesEvent = await getEvent(
|
||||
{
|
||||
...event,
|
||||
uid: event.uid.split("/")[0],
|
||||
},
|
||||
true
|
||||
);
|
||||
seriesEvent.exdates = [...(seriesEvent.exdates || []), event.start];
|
||||
delete seriesEvent.recurrenceId;
|
||||
|
||||
return putEvent(seriesEvent, calOwnerEmail);
|
||||
};
|
||||
|
||||
export const updateSeries = async (
|
||||
event: CalendarEvent,
|
||||
calOwnerEmail?: string
|
||||
) => {
|
||||
const vevents = await getAllRecurrentEvent(event);
|
||||
const masterIndex = vevents.findIndex(
|
||||
([, props]: [string, string[]]) =>
|
||||
!props.find(([k]) => k.toLowerCase() === "recurrence-id")
|
||||
);
|
||||
if (masterIndex === -1) {
|
||||
throw new Error("No master VEVENT found for this series");
|
||||
}
|
||||
const rrule = vevents[0][1].find(([k]: string[]) => k === "rrule");
|
||||
|
||||
const tzid = event.timezone;
|
||||
|
||||
const updatedMaster = makeVevent(event, tzid, calOwnerEmail, true);
|
||||
const newRrule = updatedMaster[1].find(([k]: string[]) => k === "rrule");
|
||||
if (!newRrule) {
|
||||
updatedMaster[1].push(rrule);
|
||||
}
|
||||
vevents[masterIndex] = updatedMaster;
|
||||
|
||||
const timezoneData = TIMEZONES.zones[event.timezone];
|
||||
const vtimezone = makeTimezone(timezoneData, event);
|
||||
|
||||
const newJCal = ["vcalendar", [], [...vevents, vtimezone.component.jCal]];
|
||||
return api(`dav${event.URL}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(newJCal),
|
||||
headers: {
|
||||
"content-type": "text/calendar; charset=utf-8",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
async function getAllRecurrentEvent(event: CalendarEvent) {
|
||||
const response = await api.get(`dav${event.URL}`);
|
||||
const eventData = await response.text();
|
||||
const jcal = ICAL.parse(eventData);
|
||||
const vevents = jcal[2].filter(([name]: string[]) => name === "vevent");
|
||||
return vevents;
|
||||
}
|
||||
|
||||
export async function moveEvent(event: CalendarEvent, newUrl: string) {
|
||||
const response = await api(`dav${event.URL}`, {
|
||||
method: "MOVE",
|
||||
|
||||
@@ -3,14 +3,6 @@ import CloseIcon from "@mui/icons-material/Close";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
|
||||
import VideocamIcon from "@mui/icons-material/Videocam";
|
||||
import {
|
||||
deleteEventAsync,
|
||||
moveEventAsync,
|
||||
putEventAsync,
|
||||
removeEvent,
|
||||
} from "../Calendars/CalendarSlice";
|
||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||
import AttendeeSelector from "../../components/Attendees/AttendeeSearch";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -33,24 +25,42 @@ import {
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||
import AttendeeSelector from "../../components/Attendees/AttendeeSearch";
|
||||
import {
|
||||
handleDelete,
|
||||
handleRSVP,
|
||||
} from "../../components/Event/eventHandlers/eventHandlers";
|
||||
import RepeatEvent from "../../components/Event/EventRepeat";
|
||||
import { InfoRow } from "../../components/Event/InfoRow";
|
||||
import { userAttendee } from "../User/userDataTypes";
|
||||
import { refreshCalendars } from "../../components/Event/utils/eventUtils";
|
||||
import { renderAttendeeBadge } from "../../components/Event/utils/eventUtils";
|
||||
import { getCalendarRange } from "../../utils/dateUtils";
|
||||
import {
|
||||
moveEventAsync,
|
||||
putEventAsync,
|
||||
removeEvent,
|
||||
updateEventInstanceAsync,
|
||||
updateSeriesAsync,
|
||||
} from "../Calendars/CalendarSlice";
|
||||
import { Calendars } from "../Calendars/CalendarTypes";
|
||||
import { userAttendee } from "../User/userDataTypes";
|
||||
import { getEvent } from "./EventApi";
|
||||
import { formatLocalDateTime } from "./EventModal";
|
||||
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
|
||||
import { renderAttendeeBadge } from "../../components/Event/utils/eventUtils";
|
||||
|
||||
export default function EventDisplayModal({
|
||||
eventId,
|
||||
calId,
|
||||
open,
|
||||
onClose,
|
||||
typeOfAction,
|
||||
}: {
|
||||
eventId: string;
|
||||
calId: string;
|
||||
open: boolean;
|
||||
onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void;
|
||||
typeOfAction?: "solo" | "all";
|
||||
}) {
|
||||
const dispatch = useAppDispatch();
|
||||
const calendar = useAppSelector((state) => state.calendars.list[calId]);
|
||||
@@ -87,7 +97,7 @@ export default function EventDisplayModal({
|
||||
const [alarm, setAlarm] = useState(event?.alarm?.trigger ?? "");
|
||||
const [busy, setBusy] = useState(event?.transp ?? "OPAQUE");
|
||||
const [eventClass, setEventClass] = useState(event?.class ?? "PUBLIC");
|
||||
const [timezone] = useState(event?.timezone ?? "UTC");
|
||||
const [timezone, setTimezone] = useState(event?.timezone ?? "UTC");
|
||||
const [newCalId, setNewCalId] = useState(event?.calId);
|
||||
const [calendarid, setCalendarid] = useState(
|
||||
calId.split("/")[0] === user.userData?.openpaasId
|
||||
@@ -119,19 +129,29 @@ export default function EventDisplayModal({
|
||||
}
|
||||
setRepetition(event?.repetition ?? ({} as RepetitionObject));
|
||||
}, [open, eventId, dispatch, onClose, event]);
|
||||
useEffect(() => {
|
||||
const fetchMasterEvent = async () => {
|
||||
const masterEvent = await getEvent(event);
|
||||
|
||||
setTitle(masterEvent.title ?? "");
|
||||
setDescription(masterEvent.description ?? "");
|
||||
setLocation(masterEvent.location ?? "");
|
||||
setStart(formatLocalDateTime(new Date(masterEvent?.start ?? Date.now())));
|
||||
setEnd(formatLocalDateTime(new Date(masterEvent?.end ?? Date.now())));
|
||||
setAllDay(masterEvent.allday ?? false);
|
||||
setRepetition(masterEvent?.repetition ?? ({} as RepetitionObject));
|
||||
setAlarm(masterEvent?.alarm?.trigger ?? "");
|
||||
setBusy(masterEvent?.transp ?? "OPAQUE");
|
||||
setEventClass(masterEvent?.class ?? "PUBLIC");
|
||||
setTimezone(masterEvent.timezone ?? "UTC");
|
||||
};
|
||||
if (typeOfAction === "all") {
|
||||
fetchMasterEvent();
|
||||
}
|
||||
}, [typeOfAction, event]);
|
||||
|
||||
if (!event || !calendar) return null;
|
||||
|
||||
function handleRSVP(rsvp: string) {
|
||||
const newEvent = {
|
||||
...event,
|
||||
attendee: event.attendee?.map((a) =>
|
||||
a.cal_address === user.userData.email ? { ...a, partstat: rsvp } : a
|
||||
),
|
||||
};
|
||||
|
||||
dispatch(putEventAsync({ cal: calendar, newEvent }));
|
||||
}
|
||||
const isRecurring = event.uid?.includes("/");
|
||||
|
||||
const handleSave = async () => {
|
||||
const newEventUID = crypto.randomUUID();
|
||||
@@ -157,28 +177,35 @@ export default function EventDisplayModal({
|
||||
};
|
||||
|
||||
const [baseId, recurrenceId] = event.uid.split("/");
|
||||
if (recurrenceId) {
|
||||
Object.keys(userPersonnalCalendars[calendarid].events).forEach(
|
||||
(element) => {
|
||||
if (element.split("/")[0] === baseId) {
|
||||
dispatch(removeEvent({ calendarUid: calId, eventUid: element }));
|
||||
}
|
||||
}
|
||||
const calendarRange = getCalendarRange(new Date(start));
|
||||
|
||||
if (typeOfAction === "solo") {
|
||||
dispatch(
|
||||
updateEventInstanceAsync({
|
||||
cal: userPersonnalCalendars[calendarid],
|
||||
event: { ...newEvent, recurrenceId: recurrenceId },
|
||||
})
|
||||
);
|
||||
} else if (typeOfAction === "all") {
|
||||
dispatch(
|
||||
updateSeriesAsync({
|
||||
cal: userPersonnalCalendars[calendarid],
|
||||
event: { ...newEvent, recurrenceId: recurrenceId },
|
||||
})
|
||||
);
|
||||
await refreshCalendars(dispatch, calendars, calendarRange);
|
||||
} else {
|
||||
dispatch(
|
||||
putEventAsync({ cal: userPersonnalCalendars[calendarid], newEvent })
|
||||
);
|
||||
}
|
||||
await dispatch(
|
||||
putEventAsync({
|
||||
cal: userPersonnalCalendars[calendarid],
|
||||
newEvent,
|
||||
})
|
||||
);
|
||||
|
||||
if (newCalId !== calId) {
|
||||
dispatch(
|
||||
moveEventAsync({
|
||||
cal: userPersonnalCalendars[calendarid],
|
||||
newEvent,
|
||||
newURL: `/calendars/${newCalId}/${event.uid}.ics`,
|
||||
newURL: `/calendars/${newCalId}/${baseId}.ics`,
|
||||
})
|
||||
);
|
||||
dispatch(removeEvent({ calendarUid: calId, eventUid: event.uid }));
|
||||
@@ -268,7 +295,17 @@ export default function EventDisplayModal({
|
||||
? "success"
|
||||
: "primary"
|
||||
}
|
||||
onClick={() => handleRSVP("ACCEPTED")}
|
||||
onClick={() =>
|
||||
handleRSVP(
|
||||
dispatch,
|
||||
calendar,
|
||||
user,
|
||||
event,
|
||||
"ACCEPTED",
|
||||
undefined,
|
||||
isRecurring ? typeOfAction : undefined
|
||||
)
|
||||
}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
@@ -278,7 +315,17 @@ export default function EventDisplayModal({
|
||||
? "warning"
|
||||
: "primary"
|
||||
}
|
||||
onClick={() => handleRSVP("TENTATIVE")}
|
||||
onClick={() =>
|
||||
handleRSVP(
|
||||
dispatch,
|
||||
calendar,
|
||||
user,
|
||||
event,
|
||||
"TENTATIVE",
|
||||
undefined,
|
||||
isRecurring ? typeOfAction : undefined
|
||||
)
|
||||
}
|
||||
>
|
||||
Maybe
|
||||
</Button>
|
||||
@@ -288,7 +335,17 @@ export default function EventDisplayModal({
|
||||
? "error"
|
||||
: "primary"
|
||||
}
|
||||
onClick={() => handleRSVP("DECLINED")}
|
||||
onClick={() =>
|
||||
handleRSVP(
|
||||
dispatch,
|
||||
calendar,
|
||||
user,
|
||||
event,
|
||||
"DECLINED",
|
||||
undefined,
|
||||
isRecurring ? typeOfAction : undefined
|
||||
)
|
||||
}
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
@@ -463,12 +520,14 @@ export default function EventDisplayModal({
|
||||
{/* Extended options */}
|
||||
{showMore && (
|
||||
<>
|
||||
<RepeatEvent
|
||||
repetition={repetition}
|
||||
eventStart={event.start}
|
||||
setRepetition={setRepetition}
|
||||
isOwn={isOwn}
|
||||
/>
|
||||
{isOwn && (
|
||||
<RepeatEvent
|
||||
repetition={repetition}
|
||||
eventStart={event.start}
|
||||
setRepetition={setRepetition}
|
||||
isOwn={isOwn && typeOfAction !== "solo"}
|
||||
/>
|
||||
)}
|
||||
<FormControl fullWidth margin="dense" size="small">
|
||||
<InputLabel id="notification">Notification</InputLabel>
|
||||
<Select
|
||||
@@ -546,12 +605,18 @@ export default function EventDisplayModal({
|
||||
{isOwn && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
onClose({}, "backdropClick");
|
||||
dispatch(
|
||||
deleteEventAsync({ calId, eventId, eventURL: event.URL })
|
||||
);
|
||||
}}
|
||||
onClick={() =>
|
||||
handleDelete(
|
||||
isRecurring,
|
||||
typeOfAction,
|
||||
onClose,
|
||||
dispatch,
|
||||
calendar,
|
||||
event,
|
||||
calId,
|
||||
eventId
|
||||
)
|
||||
}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface CalendarEvent {
|
||||
URL: string;
|
||||
calId: string;
|
||||
uid: string;
|
||||
recurrenceId?: string;
|
||||
transp?: string;
|
||||
start: string; // ISO date string
|
||||
end?: string;
|
||||
@@ -23,6 +24,7 @@ export interface CalendarEvent {
|
||||
timezone: string;
|
||||
repetition?: RepetitionObject;
|
||||
alarm?: AlarmObject;
|
||||
exdates?: string[];
|
||||
}
|
||||
|
||||
export interface RepetitionObject {
|
||||
|
||||
@@ -79,6 +79,10 @@ export function parseCalendarEvent(
|
||||
case "recurrence-id":
|
||||
recurrenceId = value;
|
||||
break;
|
||||
case "exdate":
|
||||
if (!event.exdates) event.exdates = [];
|
||||
event.exdates.push(value);
|
||||
break;
|
||||
case "status":
|
||||
event.status = String(value);
|
||||
break;
|
||||
@@ -101,6 +105,7 @@ export function parseCalendarEvent(
|
||||
}
|
||||
if (recurrenceId && event.uid) {
|
||||
event.uid = `${event.uid}/${recurrenceId}`;
|
||||
event.recurrenceId = recurrenceId;
|
||||
}
|
||||
|
||||
if (valarm) {
|
||||
@@ -135,6 +140,36 @@ export function calendarEventToJCal(
|
||||
): any[] {
|
||||
const tzid = event.timezone; // Fallback to UTC if no timezone provided
|
||||
|
||||
const vevent: any[] = makeVevent(event, tzid, calOwnerEmail);
|
||||
|
||||
const timezoneData = TIMEZONES.zones[event.timezone];
|
||||
const vtimezone = makeTimezone(timezoneData, event);
|
||||
|
||||
return ["vcalendar", [], [vevent, vtimezone.component.jCal]];
|
||||
}
|
||||
|
||||
export function makeTimezone(
|
||||
timezoneData: { ics: string; latitude: string; longitude: string },
|
||||
event: CalendarEvent
|
||||
) {
|
||||
if (!timezoneData) {
|
||||
return new ICAL.Timezone({
|
||||
component: TIMEZONES.zones["Etc/UTC"].ics,
|
||||
tzid: "Etc/UTC",
|
||||
});
|
||||
}
|
||||
return new ICAL.Timezone({
|
||||
component: timezoneData.ics,
|
||||
tzid: event.timezone,
|
||||
});
|
||||
}
|
||||
|
||||
export function makeVevent(
|
||||
event: CalendarEvent,
|
||||
tzid: string,
|
||||
calOwnerEmail: string | undefined,
|
||||
isMasterEvent?: boolean
|
||||
) {
|
||||
const vevent: any[] = [
|
||||
"vevent",
|
||||
[
|
||||
@@ -154,6 +189,7 @@ export function calendarEventToJCal(
|
||||
event.x_openpass_videoconference ?? null,
|
||||
],
|
||||
["summary", {}, "text", event.title ?? ""],
|
||||
["dstamp", { tzid }, "date-time", formatDateToICal(new Date(), false)],
|
||||
],
|
||||
];
|
||||
if (event.alarm?.trigger) {
|
||||
@@ -201,6 +237,9 @@ export function calendarEventToJCal(
|
||||
if (event.location) {
|
||||
vevent[1].push(["location", {}, "text", event.location]);
|
||||
}
|
||||
if (event.recurrenceId && !isMasterEvent) {
|
||||
vevent[1].push(["recurrence-id", {}, "date-time", event.recurrenceId]);
|
||||
}
|
||||
if (event.description) {
|
||||
vevent[1].push(["description", {}, "text", event.description]);
|
||||
}
|
||||
@@ -242,21 +281,20 @@ export function calendarEventToJCal(
|
||||
]);
|
||||
});
|
||||
|
||||
const timezoneData = TIMEZONES.zones[event.timezone];
|
||||
if (!timezoneData) {
|
||||
const vtimezone = new ICAL.Timezone({
|
||||
component: TIMEZONES.zones["Etc/UTC"].ics,
|
||||
tzid: "Etc/UTC",
|
||||
if (event.exdates && event.exdates.length > 0) {
|
||||
event.exdates.forEach((ex) => {
|
||||
vevent[1].push([
|
||||
"exdate",
|
||||
{ tzid },
|
||||
"date-time",
|
||||
formatDateToICal(new Date(ex), false),
|
||||
]);
|
||||
});
|
||||
return ["vcalendar", [], [vevent, vtimezone.component.jCal]];
|
||||
}
|
||||
|
||||
const vtimezone = new ICAL.Timezone({
|
||||
component: timezoneData.ics,
|
||||
tzid: event.timezone,
|
||||
});
|
||||
return ["vcalendar", [], [vevent, vtimezone.component.jCal]];
|
||||
return vevent;
|
||||
}
|
||||
|
||||
function formatDateToICal(date: Date, allday: Boolean) {
|
||||
// Format date like: 2025-02-14T11:00:00 (local time)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user