Remove calendar cursor (#12200)
Fixes https://github.com/twentyhq/twenty/issues/12125 The root cause of the infinite loop was the calendar cursor. In some cases, it was not properly displayed and was causing the loop because of its animation that was always restarting. We agreed with @FelixMalfait and @Bonapara that given the current importance of the feature and the amount of issues associated, we remove the cursor for now.
This commit is contained in:
@ -12,7 +12,7 @@ import { SkeletonLoader } from '@/activities/components/SkeletonLoader';
|
||||
import { useCustomResolver } from '@/activities/hooks/useCustomResolver';
|
||||
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { TimelineCalendarEventsWithTotal } from '~/generated/graphql';
|
||||
import { H3Title } from 'twenty-ui/display';
|
||||
import {
|
||||
AnimatedPlaceholder,
|
||||
AnimatedPlaceholderEmptyContainer,
|
||||
@ -22,7 +22,7 @@ import {
|
||||
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
|
||||
Section,
|
||||
} from 'twenty-ui/layout';
|
||||
import { H3Title } from 'twenty-ui/display';
|
||||
import { TimelineCalendarEventsWithTotal } from '~/generated/graphql';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
box-sizing: border-box;
|
||||
@ -82,12 +82,9 @@ export const Calendar = ({
|
||||
|
||||
const {
|
||||
calendarEventsByDayTime,
|
||||
currentCalendarEvent,
|
||||
daysByMonthTime,
|
||||
getNextCalendarEvent,
|
||||
monthTimes,
|
||||
monthTimesByYear,
|
||||
updateCurrentCalendarEvent,
|
||||
} = useCalendarEvents(timelineCalendarEvents || []);
|
||||
|
||||
if (firstQueryLoading) {
|
||||
@ -119,10 +116,6 @@ export const Calendar = ({
|
||||
<CalendarContext.Provider
|
||||
value={{
|
||||
calendarEventsByDayTime,
|
||||
currentCalendarEvent,
|
||||
displayCurrentEventCursor: true,
|
||||
getNextCalendarEvent,
|
||||
updateCurrentCalendarEvent,
|
||||
}}
|
||||
>
|
||||
<StyledContainer>
|
||||
|
||||
@ -1,180 +0,0 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import {
|
||||
differenceInSeconds,
|
||||
isThisMonth,
|
||||
startOfDay,
|
||||
startOfMonth,
|
||||
} from 'date-fns';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useContext, useMemo, useState } from 'react';
|
||||
|
||||
import { CalendarContext } from '@/activities/calendar/contexts/CalendarContext';
|
||||
import { getCalendarEventEndDate } from '@/activities/calendar/utils/getCalendarEventEndDate';
|
||||
import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate';
|
||||
import { hasCalendarEventEnded } from '@/activities/calendar/utils/hasCalendarEventEnded';
|
||||
import { hasCalendarEventStarted } from '@/activities/calendar/utils/hasCalendarEventStarted';
|
||||
import { TimelineCalendarEvent } from '~/generated/graphql';
|
||||
|
||||
type CalendarCurrentEventCursorProps = {
|
||||
calendarEvent: TimelineCalendarEvent;
|
||||
};
|
||||
|
||||
const StyledDot = styled(motion.div)`
|
||||
background-color: ${({ theme }) => theme.font.color.danger};
|
||||
border-radius: 1px;
|
||||
height: ${({ theme }) => theme.spacing(1)};
|
||||
width: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledCurrentEventCursor = styled(motion.div)`
|
||||
align-items: center;
|
||||
background-color: ${({ theme }) => theme.font.color.danger};
|
||||
display: inline-flex;
|
||||
height: 1.5px;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
transform: translateY(-50%);
|
||||
`;
|
||||
|
||||
export const CalendarCurrentEventCursor = ({
|
||||
calendarEvent,
|
||||
}: CalendarCurrentEventCursorProps) => {
|
||||
const theme = useTheme();
|
||||
const {
|
||||
calendarEventsByDayTime,
|
||||
currentCalendarEvent,
|
||||
getNextCalendarEvent,
|
||||
updateCurrentCalendarEvent,
|
||||
} = useContext(CalendarContext);
|
||||
|
||||
const nextCalendarEvent = getNextCalendarEvent(calendarEvent);
|
||||
const nextCalendarEventStartsAt = nextCalendarEvent
|
||||
? getCalendarEventStartDate(nextCalendarEvent)
|
||||
: undefined;
|
||||
const isNextEventThisMonth =
|
||||
!!nextCalendarEventStartsAt && isThisMonth(nextCalendarEventStartsAt);
|
||||
|
||||
const calendarEventStartsAt = getCalendarEventStartDate(calendarEvent);
|
||||
const calendarEventEndsAt = getCalendarEventEndDate(calendarEvent);
|
||||
|
||||
const isCurrent = currentCalendarEvent?.id === calendarEvent.id;
|
||||
const [hasStarted, setHasStarted] = useState(
|
||||
hasCalendarEventStarted(calendarEvent),
|
||||
);
|
||||
const [hasEnded, setHasEnded] = useState(
|
||||
hasCalendarEventEnded(calendarEvent),
|
||||
);
|
||||
const [isWaiting, setIsWaiting] = useState(hasEnded && !isNextEventThisMonth);
|
||||
|
||||
const dayTime = startOfDay(calendarEventStartsAt).getTime();
|
||||
const dayEvents = calendarEventsByDayTime[dayTime];
|
||||
const isFirstEventOfDay = dayEvents?.slice(-1)[0] === calendarEvent;
|
||||
const isLastEventOfDay = dayEvents?.[0] === calendarEvent;
|
||||
|
||||
const topOffset = isLastEventOfDay ? 9 : 6;
|
||||
const bottomOffset = isFirstEventOfDay ? 9 : 6;
|
||||
|
||||
const currentEventCursorVariants = {
|
||||
beforeEvent: { top: `calc(100% + ${bottomOffset}px)` },
|
||||
eventStart: {
|
||||
top: 'calc(100% + 3px)',
|
||||
transition: {
|
||||
delay: Math.max(
|
||||
0,
|
||||
differenceInSeconds(calendarEventStartsAt, new Date()),
|
||||
),
|
||||
},
|
||||
},
|
||||
eventEnd: {
|
||||
top: `-${topOffset}px`,
|
||||
transition: {
|
||||
delay: Math.max(
|
||||
0,
|
||||
differenceInSeconds(calendarEventEndsAt, new Date()) + 1,
|
||||
),
|
||||
},
|
||||
},
|
||||
fadeAway: {
|
||||
opacity: 0,
|
||||
top: `-${topOffset}px`,
|
||||
transition: {
|
||||
delay:
|
||||
isWaiting && nextCalendarEventStartsAt
|
||||
? differenceInSeconds(
|
||||
startOfMonth(nextCalendarEventStartsAt),
|
||||
new Date(),
|
||||
)
|
||||
: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const animationSequence = useMemo(() => {
|
||||
if (!hasStarted) return { initial: 'beforeEvent', animate: 'eventStart' };
|
||||
|
||||
if (!hasEnded) {
|
||||
return { initial: 'eventStart', animate: 'eventEnd' };
|
||||
}
|
||||
|
||||
if (!isWaiting) {
|
||||
return { initial: undefined, animate: 'eventEnd' };
|
||||
}
|
||||
|
||||
return { initial: 'eventEnd', animate: 'fadeAway' };
|
||||
}, [hasEnded, hasStarted, isWaiting]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isCurrent && (
|
||||
<StyledCurrentEventCursor
|
||||
key={calendarEvent.id}
|
||||
initial={animationSequence.initial}
|
||||
animate={animationSequence.animate}
|
||||
exit="fadeAway"
|
||||
variants={currentEventCursorVariants}
|
||||
onAnimationComplete={(stage) => {
|
||||
if (stage === 'eventStart') {
|
||||
setHasStarted(true);
|
||||
}
|
||||
|
||||
if (stage === 'eventEnd') {
|
||||
setHasEnded(true);
|
||||
|
||||
if (isNextEventThisMonth) {
|
||||
updateCurrentCalendarEvent();
|
||||
}
|
||||
// If the next event is not the same month as the current event,
|
||||
// we don't want the cursor to jump to the next month until the next month starts.
|
||||
// => Wait for the upcoming event's month to start before moving the cursor.
|
||||
// Example: we're in March. The previous event is February 15th, and the next event is April 10th.
|
||||
// We want the cursor to stay in February until April starts.
|
||||
else {
|
||||
setIsWaiting(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (isWaiting && stage === 'fadeAway') {
|
||||
setIsWaiting(false);
|
||||
updateCurrentCalendarEvent();
|
||||
}
|
||||
}}
|
||||
transition={{ duration: theme.animation.duration.normal }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{
|
||||
delay: theme.animation.duration.normal,
|
||||
duration: theme.animation.duration.normal,
|
||||
}}
|
||||
>
|
||||
<StyledDot />
|
||||
</motion.div>
|
||||
</StyledCurrentEventCursor>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
@ -4,8 +4,8 @@ import { differenceInSeconds, endOfDay, format } from 'date-fns';
|
||||
|
||||
import { CalendarEventRow } from '@/activities/calendar/components/CalendarEventRow';
|
||||
import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate';
|
||||
import { TimelineCalendarEvent } from '~/generated/graphql';
|
||||
import { CardContent } from 'twenty-ui/layout';
|
||||
import { TimelineCalendarEvent } from '~/generated/graphql';
|
||||
|
||||
type CalendarDayCardContentProps = {
|
||||
calendarEvents: TimelineCalendarEvent[];
|
||||
|
||||
@ -1,13 +1,10 @@
|
||||
import { css, useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { format } from 'date-fns';
|
||||
import { useContext } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { CalendarCurrentEventCursor } from '@/activities/calendar/components/CalendarCurrentEventCursor';
|
||||
import { CalendarEventNotSharedContent } from '@/activities/calendar/components/CalendarEventNotSharedContent';
|
||||
import { CalendarEventParticipantsAvatarGroup } from '@/activities/calendar/components/CalendarEventParticipantsAvatarGroup';
|
||||
import { CalendarContext } from '@/activities/calendar/contexts/CalendarContext';
|
||||
import { getCalendarEventEndDate } from '@/activities/calendar/utils/getCalendarEventEndDate';
|
||||
import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate';
|
||||
import { hasCalendarEventEnded } from '@/activities/calendar/utils/hasCalendarEventEnded';
|
||||
@ -88,7 +85,6 @@ export const CalendarEventRow = ({
|
||||
}: CalendarEventRowProps) => {
|
||||
const theme = useTheme();
|
||||
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||
const { displayCurrentEventCursor = false } = useContext(CalendarContext);
|
||||
const { openCalendarEventInCommandMenu } =
|
||||
useOpenCalendarEventInCommandMenu();
|
||||
|
||||
@ -143,9 +139,6 @@ export const CalendarEventRow = ({
|
||||
participants={calendarEvent.participants}
|
||||
/>
|
||||
)}
|
||||
{displayCurrentEventCursor && (
|
||||
<CalendarCurrentEventCursor calendarEvent={calendarEvent} />
|
||||
)}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@ -4,16 +4,8 @@ import { TimelineCalendarEvent } from '~/generated/graphql';
|
||||
|
||||
type CalendarContextValue = {
|
||||
calendarEventsByDayTime: Record<number, TimelineCalendarEvent[] | undefined>;
|
||||
currentCalendarEvent?: TimelineCalendarEvent;
|
||||
displayCurrentEventCursor?: boolean;
|
||||
getNextCalendarEvent: (
|
||||
calendarEvent: TimelineCalendarEvent,
|
||||
) => TimelineCalendarEvent | undefined;
|
||||
updateCurrentCalendarEvent: () => void;
|
||||
};
|
||||
|
||||
export const CalendarContext = createContext<CalendarContextValue>({
|
||||
calendarEventsByDayTime: {},
|
||||
getNextCalendarEvent: () => undefined,
|
||||
updateCurrentCalendarEvent: () => {},
|
||||
});
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useCalendarEvents } from '@/activities/calendar/hooks/useCalendarEvents';
|
||||
import {
|
||||
@ -85,20 +85,13 @@ const calendarEvents: TimelineCalendarEvent[] = [
|
||||
},
|
||||
];
|
||||
|
||||
describe('useCalendar', () => {
|
||||
it('returns calendar events', () => {
|
||||
describe('useCalendarEvents', () => {
|
||||
it('returns calendar events grouped by day time', () => {
|
||||
const { result } = renderHook(() => useCalendarEvents(calendarEvents));
|
||||
|
||||
expect(result.current.currentCalendarEvent).toBe(calendarEvents[0]);
|
||||
|
||||
expect(result.current.getNextCalendarEvent(calendarEvents[1])).toBe(
|
||||
calendarEvents[0],
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.updateCurrentCalendarEvent();
|
||||
});
|
||||
|
||||
expect(result.current.currentCalendarEvent).toBe(calendarEvents[0]);
|
||||
expect(result.current.calendarEventsByDayTime).toBeDefined();
|
||||
expect(result.current.daysByMonthTime).toBeDefined();
|
||||
expect(result.current.monthTimes).toBeDefined();
|
||||
expect(result.current.monthTimesByYear).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,12 +1,9 @@
|
||||
import { getYear, isThisMonth, startOfDay, startOfMonth } from 'date-fns';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { getYear, startOfDay, startOfMonth } from 'date-fns';
|
||||
|
||||
import { findUpcomingCalendarEvent } from '@/activities/calendar/utils/findUpcomingCalendarEvent';
|
||||
import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate';
|
||||
import { TimelineCalendarEvent } from '~/generated/graphql';
|
||||
import { groupArrayItemsBy } from '~/utils/array/groupArrayItemsBy';
|
||||
import { sortDesc } from '~/utils/sort';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const useCalendarEvents = (calendarEvents: TimelineCalendarEvent[]) => {
|
||||
const calendarEventsByDayTime = groupArrayItemsBy(
|
||||
@ -29,53 +26,10 @@ export const useCalendarEvents = (calendarEvents: TimelineCalendarEvent[]) => {
|
||||
|
||||
const monthTimesByYear = groupArrayItemsBy(sortedMonthTimes, getYear);
|
||||
|
||||
const getPreviousCalendarEvent = (calendarEvent: TimelineCalendarEvent) => {
|
||||
const calendarEventIndex = calendarEvents.indexOf(calendarEvent);
|
||||
return calendarEventIndex < calendarEvents.length - 1
|
||||
? calendarEvents[calendarEventIndex + 1]
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const getNextCalendarEvent = (calendarEvent: TimelineCalendarEvent) => {
|
||||
const calendarEventIndex = calendarEvents.indexOf(calendarEvent);
|
||||
return calendarEventIndex > 0
|
||||
? calendarEvents[calendarEventIndex - 1]
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const initialUpcomingCalendarEvent = useMemo(
|
||||
() => findUpcomingCalendarEvent(calendarEvents),
|
||||
[calendarEvents],
|
||||
);
|
||||
const lastEventInCalendar = calendarEvents.length
|
||||
? calendarEvents[0]
|
||||
: undefined;
|
||||
|
||||
const [currentCalendarEvent, setCurrentCalendarEvent] = useState(
|
||||
(initialUpcomingCalendarEvent &&
|
||||
(isThisMonth(getCalendarEventStartDate(initialUpcomingCalendarEvent))
|
||||
? initialUpcomingCalendarEvent
|
||||
: getPreviousCalendarEvent(initialUpcomingCalendarEvent))) ||
|
||||
lastEventInCalendar,
|
||||
);
|
||||
|
||||
const updateCurrentCalendarEvent = () => {
|
||||
if (!currentCalendarEvent) return;
|
||||
|
||||
const nextCurrentCalendarEvent = getNextCalendarEvent(currentCalendarEvent);
|
||||
|
||||
if (isDefined(nextCurrentCalendarEvent)) {
|
||||
setCurrentCalendarEvent(nextCurrentCalendarEvent);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
calendarEventsByDayTime,
|
||||
currentCalendarEvent,
|
||||
daysByMonthTime,
|
||||
getNextCalendarEvent,
|
||||
monthTimes: sortedMonthTimes,
|
||||
monthTimesByYear,
|
||||
updateCurrentCalendarEvent,
|
||||
};
|
||||
};
|
||||
|
||||
@ -1,80 +0,0 @@
|
||||
import { addDays, addHours, startOfDay, subDays, subHours } from 'date-fns';
|
||||
|
||||
import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent';
|
||||
|
||||
import { findUpcomingCalendarEvent } from '../findUpcomingCalendarEvent';
|
||||
|
||||
const pastEvent: Pick<CalendarEvent, 'startsAt' | 'endsAt' | 'isFullDay'> = {
|
||||
startsAt: subHours(new Date(), 2).toISOString(),
|
||||
endsAt: subHours(new Date(), 1).toISOString(),
|
||||
isFullDay: false,
|
||||
};
|
||||
const fullDayPastEvent: Pick<CalendarEvent, 'startsAt' | 'isFullDay'> = {
|
||||
startsAt: subDays(new Date(), 1).toISOString(),
|
||||
isFullDay: true,
|
||||
};
|
||||
|
||||
const currentEvent: Pick<CalendarEvent, 'startsAt' | 'endsAt' | 'isFullDay'> = {
|
||||
startsAt: addHours(new Date(), 1).toISOString(),
|
||||
endsAt: addHours(new Date(), 2).toISOString(),
|
||||
isFullDay: false,
|
||||
};
|
||||
const currentFullDayEvent: Pick<CalendarEvent, 'startsAt' | 'isFullDay'> = {
|
||||
startsAt: startOfDay(new Date()).toISOString(),
|
||||
isFullDay: true,
|
||||
};
|
||||
|
||||
const futureEvent: Pick<CalendarEvent, 'startsAt' | 'endsAt' | 'isFullDay'> = {
|
||||
startsAt: addDays(new Date(), 1).toISOString(),
|
||||
endsAt: addDays(new Date(), 2).toISOString(),
|
||||
isFullDay: false,
|
||||
};
|
||||
const fullDayFutureEvent: Pick<CalendarEvent, 'startsAt' | 'isFullDay'> = {
|
||||
startsAt: addDays(new Date(), 2).toISOString(),
|
||||
isFullDay: false,
|
||||
};
|
||||
|
||||
describe('findUpcomingCalendarEvent', () => {
|
||||
it('returns the first current event by chronological order', () => {
|
||||
// Given
|
||||
const calendarEvents = [
|
||||
futureEvent,
|
||||
currentFullDayEvent,
|
||||
pastEvent,
|
||||
currentEvent,
|
||||
];
|
||||
|
||||
// When
|
||||
const result = findUpcomingCalendarEvent(calendarEvents);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(currentFullDayEvent);
|
||||
});
|
||||
|
||||
it('returns the next future event by chronological order', () => {
|
||||
// Given
|
||||
const calendarEvents = [
|
||||
fullDayPastEvent,
|
||||
fullDayFutureEvent,
|
||||
futureEvent,
|
||||
pastEvent,
|
||||
];
|
||||
|
||||
// When
|
||||
const result = findUpcomingCalendarEvent(calendarEvents);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(futureEvent);
|
||||
});
|
||||
|
||||
it('returns undefined if all events are in the past', () => {
|
||||
// Given
|
||||
const calendarEvents = [pastEvent, fullDayPastEvent];
|
||||
|
||||
// When
|
||||
const result = findUpcomingCalendarEvent(calendarEvents);
|
||||
|
||||
// Then
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@ -1,12 +0,0 @@
|
||||
import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent';
|
||||
import { hasCalendarEventEnded } from '@/activities/calendar/utils/hasCalendarEventEnded';
|
||||
import { sortCalendarEventsAsc } from '@/activities/calendar/utils/sortCalendarEvents';
|
||||
|
||||
export const findUpcomingCalendarEvent = <
|
||||
T extends Pick<CalendarEvent, 'startsAt' | 'endsAt' | 'isFullDay'>,
|
||||
>(
|
||||
calendarEvents: T[],
|
||||
) =>
|
||||
[...calendarEvents]
|
||||
.sort(sortCalendarEventsAsc)
|
||||
.find((calendarEvent) => !hasCalendarEventEnded(calendarEvent));
|
||||
@ -3,15 +3,15 @@ import { CalendarContext } from '@/activities/calendar/contexts/CalendarContext'
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { SettingsAccountsCalendarDisplaySettings } from '@/settings/accounts/components/SettingsAccountsCalendarDisplaySettings';
|
||||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Section } from '@react-email/components';
|
||||
import { addMinutes, endOfDay, min, startOfDay } from 'date-fns';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { H2Title } from 'twenty-ui/display';
|
||||
import {
|
||||
CalendarChannelVisibility,
|
||||
TimelineCalendarEvent,
|
||||
} from '~/generated/graphql';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { H2Title } from 'twenty-ui/display';
|
||||
|
||||
const StyledGeneralContainer = styled.div`
|
||||
display: flex;
|
||||
@ -78,12 +78,9 @@ export const SettingsAccountsCalendarChannelsGeneral = () => {
|
||||
/>
|
||||
<CalendarContext.Provider
|
||||
value={{
|
||||
currentCalendarEvent: exampleCalendarEvent,
|
||||
calendarEventsByDayTime: {
|
||||
[exampleDayTime]: [exampleCalendarEvent],
|
||||
},
|
||||
getNextCalendarEvent: () => undefined,
|
||||
updateCurrentCalendarEvent: () => {},
|
||||
}}
|
||||
>
|
||||
<CalendarMonthCard dayTimes={[exampleDayTime]} />
|
||||
|
||||
Reference in New Issue
Block a user