mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
fix: ask before the event and task editors drop typed changes on close or back
This commit is contained in:
@@ -79,6 +79,7 @@ import { SafeAreaModal } from '../SafeAreaModal';
|
|||||||
import { useKeyboardObstruction } from '../../lib/use-keyboard-obstruction';
|
import { useKeyboardObstruction } from '../../lib/use-keyboard-obstruction';
|
||||||
import { editorKeyboardInset } from '../../lib/calendar-editor-keyboard';
|
import { editorKeyboardInset } from '../../lib/calendar-editor-keyboard';
|
||||||
import ToggleSwitch from '../ToggleSwitch';
|
import ToggleSwitch from '../ToggleSwitch';
|
||||||
|
import { askBeforeDiscard, editorFormChanged, editorFormSnapshot } from '../../lib/calendar-editor-discard';
|
||||||
|
|
||||||
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
|
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
|
||||||
|
|
||||||
@@ -254,12 +255,34 @@ export function EventModal({
|
|||||||
const [showEndTime, setShowEndTime] = React.useState(false);
|
const [showEndTime, setShowEndTime] = React.useState(false);
|
||||||
const [saving, setSaving] = React.useState(false);
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
|
||||||
|
// What the form holds against what it opened with: Close and Back ask
|
||||||
|
// before they drop changes (webmail 13f9f27c4).
|
||||||
|
const formSnapshot = React.useMemo(() => editorFormSnapshot({
|
||||||
|
title, description, allDay, start, end, calendarId, attendees, recurrence, customRule,
|
||||||
|
reminders, useDefaults, preserved, sendInvitations, location, videoUrl,
|
||||||
|
}), [title, description, allDay, start, end, calendarId, attendees, recurrence, customRule,
|
||||||
|
reminders, useDefaults, preserved, sendInvitations, location, videoUrl]);
|
||||||
|
const [baseline, setBaseline] = React.useState<string | null>(null);
|
||||||
|
// Before the reset below: the reset clears the baseline after this took
|
||||||
|
// the form of the render before it, and the next render takes the
|
||||||
|
// seeded form.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
if (baseline !== null) setBaseline(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (baseline === null) setBaseline(formSnapshot);
|
||||||
|
}, [visible, baseline, formSnapshot]);
|
||||||
|
const dirty = visible && editorFormChanged(baseline, formSnapshot);
|
||||||
|
const requestClose = () => askBeforeDiscard(dirty, t, onClose);
|
||||||
|
|
||||||
// Reset the form when the editor opens or switches to another event only.
|
// Reset the form when the editor opens or switches to another event only.
|
||||||
// `calendars` and `currentUserEmails` get new identities when a calendar
|
// `calendars` and `currentUserEmails` get new identities when a calendar
|
||||||
// push or the alias lookup lands, which used to wipe what the user typed.
|
// push or the alias lookup lands, which used to wipe what the user typed.
|
||||||
const formKey = visible ? event?.id ?? 'new' : null;
|
const formKey = visible ? event?.id ?? 'new' : null;
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!visible) return;
|
if (!visible) return;
|
||||||
|
setBaseline(null);
|
||||||
if (event) {
|
if (event) {
|
||||||
setTitle(event.title || '');
|
setTitle(event.title || '');
|
||||||
setDescription(event.description || '');
|
setDescription(event.description || '');
|
||||||
@@ -306,7 +329,11 @@ export function EventModal({
|
|||||||
|
|
||||||
// The calendars can arrive after a new-event form opened.
|
// The calendars can arrive after a new-event form opened.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (visible && !calendarId && fallbackCalendarId) setCalendarId(fallbackCalendarId);
|
if (visible && !calendarId && fallbackCalendarId) {
|
||||||
|
setCalendarId(fallbackCalendarId);
|
||||||
|
// Not a change of the user's.
|
||||||
|
setBaseline(null);
|
||||||
|
}
|
||||||
}, [visible, calendarId, fallbackCalendarId]);
|
}, [visible, calendarId, fallbackCalendarId]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -492,12 +519,12 @@ export function EventModal({
|
|||||||
visible={visible}
|
visible={visible}
|
||||||
animationType="slide"
|
animationType="slide"
|
||||||
presentationStyle="fullScreen"
|
presentationStyle="fullScreen"
|
||||||
onRequestClose={onClose}
|
onRequestClose={requestClose}
|
||||||
>
|
>
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']} onLayout={onContainerLayout}>
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']} onLayout={onContainerLayout}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onClose}
|
onPress={requestClose}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
style={styles.headerBtn}
|
style={styles.headerBtn}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
|
|||||||
@@ -34,10 +34,12 @@ import {
|
|||||||
emptyTaskEditor,
|
emptyTaskEditor,
|
||||||
priorityToLevel,
|
priorityToLevel,
|
||||||
submitTaskEditor,
|
submitTaskEditor,
|
||||||
|
taskEditorChanged,
|
||||||
taskEditorFromTask,
|
taskEditorFromTask,
|
||||||
type PriorityLevel,
|
type PriorityLevel,
|
||||||
type TaskEditorState,
|
type TaskEditorState,
|
||||||
} from '../../lib/task-editor';
|
} from '../../lib/task-editor';
|
||||||
|
import { askBeforeDiscard } from '../../lib/calendar-editor-discard';
|
||||||
import { isWritableCalendar } from '../../lib/calendar-editability';
|
import { isWritableCalendar } from '../../lib/calendar-editability';
|
||||||
import { useCalendarLocale } from '../../lib/calendar-locale';
|
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||||
import { formatCalendarDate } from '../../lib/date-format';
|
import { formatCalendarDate } from '../../lib/date-format';
|
||||||
@@ -113,10 +115,13 @@ export function TasksSheet({
|
|||||||
// Centred on a wide window, clear of side insets in landscape.
|
// Centred on a wide window, clear of side insets in landscape.
|
||||||
const sheetInsets = useSheetInsets();
|
const sheetInsets = useSheetInsets();
|
||||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||||
|
// Close, Back, a tap beside the sheet and a drag down ask before they drop
|
||||||
|
// a changed task (webmail 13f9f27c4); kept, a dragged sheet comes back.
|
||||||
|
const closeRef = React.useRef<() => void>(onClose);
|
||||||
const dragHandlers = useSheetDrag({
|
const dragHandlers = useSheetDrag({
|
||||||
slideY,
|
slideY,
|
||||||
closedY: Dimensions.get('window').height,
|
closedY: Dimensions.get('window').height,
|
||||||
onClose,
|
onClose: React.useCallback(() => closeRef.current(), []),
|
||||||
});
|
});
|
||||||
|
|
||||||
const writableCalendars = React.useMemo(
|
const writableCalendars = React.useMemo(
|
||||||
@@ -127,6 +132,12 @@ export function TasksSheet({
|
|||||||
|
|
||||||
const [filter, setFilter] = React.useState<TaskFilter>('all');
|
const [filter, setFilter] = React.useState<TaskFilter>('all');
|
||||||
const [editor, setEditor] = React.useState<TaskEditorState>(() => emptyTaskEditor(defaultCalendarId));
|
const [editor, setEditor] = React.useState<TaskEditorState>(() => emptyTaskEditor(defaultCalendarId));
|
||||||
|
// The task as the editor was opened on it, to tell typed changes.
|
||||||
|
const [baseline, setBaseline] = React.useState<TaskEditorState>(editor);
|
||||||
|
const seedEditor = (state: TaskEditorState) => {
|
||||||
|
setEditor(state);
|
||||||
|
setBaseline(state);
|
||||||
|
};
|
||||||
const [expanded, setExpanded] = React.useState(false);
|
const [expanded, setExpanded] = React.useState(false);
|
||||||
const [showDatePicker, setShowDatePicker] = React.useState(false);
|
const [showDatePicker, setShowDatePicker] = React.useState(false);
|
||||||
const [showTimePicker, setShowTimePicker] = React.useState(false);
|
const [showTimePicker, setShowTimePicker] = React.useState(false);
|
||||||
@@ -136,10 +147,10 @@ export function TasksSheet({
|
|||||||
if (!visible) return;
|
if (!visible) return;
|
||||||
const initial = initialTaskId ? tasks.find((task) => task.id === initialTaskId) : undefined;
|
const initial = initialTaskId ? tasks.find((task) => task.id === initialTaskId) : undefined;
|
||||||
if (initial) {
|
if (initial) {
|
||||||
setEditor(taskEditorFromTask(initial, defaultCalendarId));
|
seedEditor(taskEditorFromTask(initial, defaultCalendarId));
|
||||||
setExpanded(true);
|
setExpanded(true);
|
||||||
} else {
|
} else {
|
||||||
setEditor(emptyTaskEditor(defaultCalendarId));
|
seedEditor(emptyTaskEditor(defaultCalendarId));
|
||||||
setExpanded(startNew);
|
setExpanded(startNew);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -148,6 +159,7 @@ export function TasksSheet({
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (visible && !editor.calendarId && defaultCalendarId) {
|
if (visible && !editor.calendarId && defaultCalendarId) {
|
||||||
setEditor((e) => ({ ...e, calendarId: defaultCalendarId }));
|
setEditor((e) => ({ ...e, calendarId: defaultCalendarId }));
|
||||||
|
setBaseline((b) => (b.calendarId ? b : { ...b, calendarId: defaultCalendarId }));
|
||||||
}
|
}
|
||||||
}, [visible, editor.calendarId, defaultCalendarId]);
|
}, [visible, editor.calendarId, defaultCalendarId]);
|
||||||
|
|
||||||
@@ -189,10 +201,17 @@ export function TasksSheet({
|
|||||||
}), [tasks]);
|
}), [tasks]);
|
||||||
|
|
||||||
const resetEditor = () => {
|
const resetEditor = () => {
|
||||||
setEditor(emptyTaskEditor(defaultCalendarId));
|
seedEditor(emptyTaskEditor(defaultCalendarId));
|
||||||
setExpanded(false);
|
setExpanded(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dirty = visible && taskEditorChanged(baseline, editor);
|
||||||
|
const springBack = () => {
|
||||||
|
Animated.spring(slideY, { toValue: 0, useNativeDriver: true, bounciness: 0 }).start();
|
||||||
|
};
|
||||||
|
const requestClose = () => askBeforeDiscard(dirty, t, onClose, springBack);
|
||||||
|
closeRef.current = requestClose;
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!editor.title.trim() || !editor.calendarId || saving) return;
|
if (!editor.title.trim() || !editor.calendarId || saving) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -235,9 +254,9 @@ export function TasksSheet({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={requestClose}>
|
||||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
<Pressable style={styles.overlayPress} onPress={requestClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
|
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
|
||||||
@@ -250,7 +269,7 @@ export function TasksSheet({
|
|||||||
<ListChecks size={20} color={c.text} />
|
<ListChecks size={20} color={c.text} />
|
||||||
<Text style={styles.headerTitle}>{t('calendar.tasks.label', 'Tasks')}</Text>
|
<Text style={styles.headerTitle}>{t('calendar.tasks.label', 'Tasks')}</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onClose}
|
onPress={requestClose}
|
||||||
style={styles.closeBtn}
|
style={styles.closeBtn}
|
||||||
hitSlop={10}
|
hitSlop={10}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -359,7 +378,7 @@ export function TasksSheet({
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
)}
|
)}
|
||||||
{editor.id && (
|
{editor.id && (
|
||||||
<Pressable onPress={resetEditor} style={styles.cancelEdit}>
|
<Pressable onPress={() => askBeforeDiscard(dirty, t, resetEditor)} style={styles.cancelEdit}>
|
||||||
<Text style={styles.cancelEditText}>{t('calendar.form.cancel', 'Cancel')}</Text>
|
<Text style={styles.cancelEditText}>{t('calendar.form.cancel', 'Cancel')}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
@@ -410,9 +429,11 @@ export function TasksSheet({
|
|||||||
<Pressable
|
<Pressable
|
||||||
style={styles.taskText}
|
style={styles.taskText}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (!onUpdate) return;
|
if (!onUpdate || editor.id === task.id) return;
|
||||||
setEditor(taskEditorFromTask(task, defaultCalendarId));
|
askBeforeDiscard(dirty, t, () => {
|
||||||
setExpanded(true);
|
seedEditor(taskEditorFromTask(task, defaultCalendarId));
|
||||||
|
setExpanded(true);
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={styles.taskTitleRow}>
|
<View style={styles.taskTitleRow}>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
const alert = vi.fn();
|
||||||
|
vi.mock('react-native', () => ({ Alert: { alert: (...args: unknown[]) => alert(...args) } }));
|
||||||
|
|
||||||
|
import { askBeforeDiscard, editorFormChanged, editorFormSnapshot } from '../calendar-editor-discard';
|
||||||
|
import { emptyTaskEditor, taskEditorChanged, taskEditorFromTask } from '../task-editor';
|
||||||
|
|
||||||
|
const t = (_key: string, fallback?: string) => fallback ?? '';
|
||||||
|
|
||||||
|
type Button = { text: string; style?: string; onPress?: () => void };
|
||||||
|
const buttons = (): Button[] => alert.mock.calls.at(-1)![2] as Button[];
|
||||||
|
|
||||||
|
beforeEach(() => alert.mockReset());
|
||||||
|
|
||||||
|
describe('askBeforeDiscard', () => {
|
||||||
|
it('closes at once when nothing was changed', () => {
|
||||||
|
const close = vi.fn();
|
||||||
|
askBeforeDiscard(false, t, close);
|
||||||
|
expect(close).toHaveBeenCalledTimes(1);
|
||||||
|
expect(alert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks before it drops changes, and closes only on Discard', () => {
|
||||||
|
const close = vi.fn();
|
||||||
|
const keep = vi.fn();
|
||||||
|
askBeforeDiscard(true, t, close, keep);
|
||||||
|
expect(close).not.toHaveBeenCalled();
|
||||||
|
expect(alert.mock.calls[0][0]).toBe('Discard changes?');
|
||||||
|
const [keepButton, discardButton] = buttons();
|
||||||
|
expect(keepButton.text).toBe('Keep editing');
|
||||||
|
keepButton.onPress?.();
|
||||||
|
expect(keep).toHaveBeenCalledTimes(1);
|
||||||
|
expect(close).not.toHaveBeenCalled();
|
||||||
|
expect(discardButton.style).toBe('destructive');
|
||||||
|
discardButton.onPress?.();
|
||||||
|
expect(close).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('editor form changes', () => {
|
||||||
|
const form = (title: string, start = new Date('2026-11-02T10:00:00Z')) =>
|
||||||
|
editorFormSnapshot({ title, start, attendees: [{ email: 'a@example.com' }] });
|
||||||
|
|
||||||
|
it('counts a typed title or a moved start as a change, the same form as none', () => {
|
||||||
|
const baseline = form('Lunch');
|
||||||
|
expect(editorFormChanged(baseline, form('Lunch'))).toBe(false);
|
||||||
|
expect(editorFormChanged(baseline, form('Lunch with Ana'))).toBe(true);
|
||||||
|
expect(editorFormChanged(baseline, form('Lunch', new Date('2026-11-02T11:00:00Z')))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts nothing before the form was seeded', () => {
|
||||||
|
expect(editorFormChanged(null, form('Lunch'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('taskEditorChanged', () => {
|
||||||
|
it('tells a typed title, note, due date or priority from the task as opened', () => {
|
||||||
|
const opened = taskEditorFromTask({ id: 't1', title: 'Pay rent', calendarIds: { c1: true } } as never, 'c1');
|
||||||
|
expect(taskEditorChanged(opened, { ...opened })).toBe(false);
|
||||||
|
expect(taskEditorChanged(opened, { ...opened, title: 'Pay rent today' })).toBe(true);
|
||||||
|
expect(taskEditorChanged(opened, { ...opened, description: 'IBAN' })).toBe(true);
|
||||||
|
expect(taskEditorChanged(opened, { ...opened, due: new Date('2026-11-03T00:00:00Z') })).toBe(true);
|
||||||
|
expect(taskEditorChanged(opened, { ...opened, priority: 'high' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compares due dates by their instant', () => {
|
||||||
|
const a = { ...emptyTaskEditor('c1'), due: new Date('2026-11-03T09:00:00Z') };
|
||||||
|
expect(taskEditorChanged(a, { ...a, due: new Date('2026-11-03T09:00:00Z') })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Alert } from 'react-native';
|
||||||
|
|
||||||
|
type Translate = (key: string, fallback?: string) => string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The editor's form as one comparable string. Dates compare by their
|
||||||
|
* instant (JSON renders them as ISO strings); the fields are compared in
|
||||||
|
* the order given, which the editor keeps fixed.
|
||||||
|
*/
|
||||||
|
export function editorFormSnapshot(fields: Record<string, unknown>): string {
|
||||||
|
return JSON.stringify(fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the form holds changes against `baseline`, the form it opened with (null: not seeded yet). */
|
||||||
|
export function editorFormChanged(baseline: string | null, current: string): boolean {
|
||||||
|
return baseline !== null && baseline !== current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `discard` at once when nothing would be lost, else after the user
|
||||||
|
* confirms "Discard"; "Keep editing" runs `keep` (webmail 13f9f27c4: the
|
||||||
|
* event and task editors ask before X, Cancel or Escape drop their changes).
|
||||||
|
*/
|
||||||
|
export function askBeforeDiscard(
|
||||||
|
dirty: boolean,
|
||||||
|
t: Translate,
|
||||||
|
discard: () => void,
|
||||||
|
keep?: () => void,
|
||||||
|
): void {
|
||||||
|
if (!dirty) {
|
||||||
|
discard();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Alert.alert(
|
||||||
|
t('calendar.discard.title', 'Discard changes?'),
|
||||||
|
t('calendar.discard.message', "Your changes haven't been saved."),
|
||||||
|
[
|
||||||
|
{ text: t('calendar.discard.keep', 'Keep editing'), style: 'cancel', onPress: keep },
|
||||||
|
{ text: t('calendar.discard.confirm', 'Discard'), style: 'destructive', onPress: discard },
|
||||||
|
],
|
||||||
|
{ cancelable: true, onDismiss: keep },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getEffectiveTimeZone,
|
getEffectiveTimeZone,
|
||||||
isValidTimeZone,
|
isValidTimeZone,
|
||||||
} from './calendar-timezone';
|
} from './calendar-timezone';
|
||||||
|
import { editorFormSnapshot } from './calendar-editor-discard';
|
||||||
|
|
||||||
export type PriorityLevel = 'none' | 'high' | 'medium' | 'low';
|
export type PriorityLevel = 'none' | 'high' | 'medium' | 'low';
|
||||||
|
|
||||||
@@ -44,6 +45,15 @@ export function emptyTaskEditor(calendarId: string): TaskEditorState {
|
|||||||
return { id: null, title: '', description: '', due: null, withTime: false, priority: 'none', calendarId };
|
return { id: null, title: '', description: '', due: null, withTime: false, priority: 'none', calendarId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether the editor holds changes against the task it was opened on (or the empty form). */
|
||||||
|
export function taskEditorChanged(baseline: TaskEditorState, current: TaskEditorState): boolean {
|
||||||
|
const key = (e: TaskEditorState) => editorFormSnapshot({
|
||||||
|
id: e.id, title: e.title, description: e.description, due: e.due?.getTime() ?? null,
|
||||||
|
withTime: e.withTime, priority: e.priority, calendarId: e.calendarId,
|
||||||
|
});
|
||||||
|
return key(baseline) !== key(current);
|
||||||
|
}
|
||||||
|
|
||||||
export function taskEditorFromTask(
|
export function taskEditorFromTask(
|
||||||
task: Pick<CalendarEvent, 'id' | 'title' | 'description' | 'due' | 'timeZone' | 'showWithoutTime' | 'priority' | 'calendarIds'>,
|
task: Pick<CalendarEvent, 'id' | 'title' | 'description' | 'due' | 'timeZone' | 'showWithoutTime' | 'priority' | 'calendarIds'>,
|
||||||
fallbackCalendarId: string,
|
fallbackCalendarId: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user