"feat: enhance user authentication flow with loading state, improved reducer handling, and login/logout actions"
This commit is contained in:
@@ -15,13 +15,18 @@ import { endpoints } from '../../pages/MyLogin/endpoints';
|
|||||||
|
|
||||||
export const loadUserData = () => async (dispatch: React.Dispatch<any>) => {
|
export const loadUserData = () => async (dispatch: React.Dispatch<any>) => {
|
||||||
dispatch(setLoading(true));
|
dispatch(setLoading(true));
|
||||||
|
|
||||||
const data = await getUserData();
|
const data = await getUserData();
|
||||||
dispatch(setData(data));
|
dispatch(setData(data));
|
||||||
|
|
||||||
dispatch(setLoading(false));
|
dispatch(setLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setLoading = (isLoading: boolean) =>
|
export const setLoading = (isLoading: boolean) =>
|
||||||
({ type: 'set-user-loading', isLoading } as const);
|
({
|
||||||
|
type: 'set-user-loading',
|
||||||
|
isLoading,
|
||||||
|
} as const);
|
||||||
|
|
||||||
export const setData = (data: Partial<UserState>) =>
|
export const setData = (data: Partial<UserState>) =>
|
||||||
({
|
({
|
||||||
@@ -30,6 +35,7 @@ export const setData = (data: Partial<UserState>) =>
|
|||||||
} as const);
|
} as const);
|
||||||
|
|
||||||
export const logoutUser = () => async (dispatch: React.Dispatch<any>) => {
|
export const logoutUser = () => async (dispatch: React.Dispatch<any>) => {
|
||||||
|
//
|
||||||
await setIsLoggedInData(false);
|
await setIsLoggedInData(false);
|
||||||
dispatch(setUsername());
|
dispatch(setUsername());
|
||||||
};
|
};
|
||||||
@@ -44,6 +50,8 @@ export const setIsLoggedIn = (loggedIn: boolean) => async (dispatch: React.Dispa
|
|||||||
|
|
||||||
export const setUsername = (username?: string) => async (dispatch: React.Dispatch<any>) => {
|
export const setUsername = (username?: string) => async (dispatch: React.Dispatch<any>) => {
|
||||||
await setUsernameData(username);
|
await setUsernameData(username);
|
||||||
|
console.log('setUsername triggered');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'set-username',
|
type: 'set-username',
|
||||||
username,
|
username,
|
||||||
@@ -52,6 +60,7 @@ export const setUsername = (username?: string) => async (dispatch: React.Dispatc
|
|||||||
|
|
||||||
export const setAccessToken = (token?: string) => async (dispatch: React.Dispatch<any>) => {
|
export const setAccessToken = (token?: string) => async (dispatch: React.Dispatch<any>) => {
|
||||||
await setAccessTokenData(token);
|
await setAccessTokenData(token);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'set-access-token',
|
type: 'set-access-token',
|
||||||
token,
|
token,
|
||||||
@@ -99,6 +108,7 @@ export const checkUserSession = () => async (dispatch: React.Dispatch<any>) => {
|
|||||||
|
|
||||||
export const setHasSeenTutorial =
|
export const setHasSeenTutorial =
|
||||||
(hasSeenTutorial: boolean) => async (dispatch: React.Dispatch<any>) => {
|
(hasSeenTutorial: boolean) => async (dispatch: React.Dispatch<any>) => {
|
||||||
|
debugger;
|
||||||
await setHasSeenTutorialData(hasSeenTutorial);
|
await setHasSeenTutorialData(hasSeenTutorial);
|
||||||
return {
|
return {
|
||||||
type: 'set-has-seen-tutorial',
|
type: 'set-has-seen-tutorial',
|
||||||
@@ -120,5 +130,4 @@ export type UserActions =
|
|||||||
| ActionType<typeof setHasSeenTutorial>
|
| ActionType<typeof setHasSeenTutorial>
|
||||||
| ActionType<typeof setDarkMode>
|
| ActionType<typeof setDarkMode>
|
||||||
| ActionType<typeof setAccessToken>
|
| ActionType<typeof setAccessToken>
|
||||||
// | ActionType<typeof setSession>
|
|
||||||
| ActionType<typeof checkUserSession>;
|
| ActionType<typeof checkUserSession>;
|
||||||
|
@@ -15,11 +15,9 @@ export function userReducer(state: UserState, action: UserActions): UserState {
|
|||||||
return { ...state, darkMode: action.darkMode };
|
return { ...state, darkMode: action.darkMode };
|
||||||
case 'set-is-loggedin':
|
case 'set-is-loggedin':
|
||||||
return { ...state, isLoggedin: action.loggedIn };
|
return { ...state, isLoggedin: action.loggedIn };
|
||||||
|
case 'set-access-token':
|
||||||
|
return { ...state, token: action.token };
|
||||||
case 'check-user-session':
|
case 'check-user-session':
|
||||||
return { ...state, isSessionValid: action.sessionValid };
|
return { ...state, isSessionValid: action.sessionValid };
|
||||||
// case 'set-active-session':
|
|
||||||
// return { ...state, session: action.session };
|
|
||||||
// case 'set-access-token':
|
|
||||||
// return { ...state, token: action.token };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,9 +1,9 @@
|
|||||||
export interface UserState {
|
export interface UserState {
|
||||||
isLoggedin: boolean;
|
|
||||||
username?: string;
|
|
||||||
darkMode: boolean;
|
|
||||||
hasSeenTutorial: boolean;
|
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
username?: string;
|
||||||
|
hasSeenTutorial: boolean;
|
||||||
|
darkMode: boolean;
|
||||||
|
isLoggedin: boolean;
|
||||||
isSessionValid: boolean;
|
isSessionValid: boolean;
|
||||||
session?: any;
|
session?: any;
|
||||||
token?: string;
|
token?: string;
|
||||||
|
@@ -74,6 +74,7 @@ const Login: React.FC<LoginProps> = (props) => {
|
|||||||
setUsername: setUsernameAction,
|
setUsername: setUsernameAction,
|
||||||
checkUserSession,
|
checkUserSession,
|
||||||
isSessionValid,
|
isSessionValid,
|
||||||
|
isLoggedin,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
@@ -103,9 +104,11 @@ const Login: React.FC<LoginProps> = (props) => {
|
|||||||
|
|
||||||
// ----------
|
// ----------
|
||||||
|
|
||||||
|
// email:
|
||||||
|
// password:
|
||||||
const defaultValues: SignInSchemaType = {
|
const defaultValues: SignInSchemaType = {
|
||||||
email: '',
|
email: 'demo@minimals.c',
|
||||||
password: '',
|
password: '@2Minimal',
|
||||||
};
|
};
|
||||||
|
|
||||||
const methods = useForm<SignInSchemaType>({
|
const methods = useForm<SignInSchemaType>({
|
||||||
@@ -137,10 +140,11 @@ const Login: React.FC<LoginProps> = (props) => {
|
|||||||
}, [isSessionValid]);
|
}, [isSessionValid]);
|
||||||
|
|
||||||
const onSubmit = handleSubmit(async (data) => {
|
const onSubmit = handleSubmit(async (data) => {
|
||||||
console.log({ data });
|
// console.log({ data });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let token = await signInWithPassword({ email: values.email, password: values.password });
|
let token = await signInWithPassword({ email: values.email, password: values.password });
|
||||||
console.log({ token });
|
// console.log({ token });
|
||||||
if (token) setAccessToken(token);
|
if (token) setAccessToken(token);
|
||||||
|
|
||||||
await checkUserSession();
|
await checkUserSession();
|
||||||
|
BIN
03_source/mobile/src/pages/MyProfile/NotLoggedIn/SignUp.png
Normal file
BIN
03_source/mobile/src/pages/MyProfile/NotLoggedIn/SignUp.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 161 KiB |
@@ -56,6 +56,7 @@ import AboutPopover from '../../../components/AboutPopover';
|
|||||||
import paths from '../../../paths';
|
import paths from '../../../paths';
|
||||||
import { getProfileById } from '../../../api/getProfileById';
|
import { getProfileById } from '../../../api/getProfileById';
|
||||||
import { defaultMember, Member } from '../../MemberProfile/type';
|
import { defaultMember, Member } from '../../MemberProfile/type';
|
||||||
|
import SignUpPng from './SignUp.png';
|
||||||
|
|
||||||
interface OwnProps {}
|
interface OwnProps {}
|
||||||
|
|
||||||
@@ -144,29 +145,53 @@ const MyProfile: React.FC<SpeakerListProps> = ({ speakers, speakerSessions }) =>
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: '50vh',
|
height: '80vh',
|
||||||
|
//
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
//
|
||||||
|
gap: '2rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
|
||||||
not login yet, <br />
|
|
||||||
please login or sign up
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: '50vh',
|
width: '95vw',
|
||||||
|
height: '95vw',
|
||||||
|
backgroundImage: `url(${SignUpPng})`,
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
//
|
||||||
|
gap: '2rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IonButton disabled={disableForwardLoginButton} onClick={handleForwardLoginPage}>
|
<div>
|
||||||
Login
|
not login yet, <br />
|
||||||
</IonButton>
|
please login or sign up
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IonButton disabled={disableForwardLoginButton} onClick={handleForwardLoginPage}>
|
||||||
|
Login
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</IonContent>
|
</IonContent>
|
||||||
|
BIN
03_source/mobile/src/pages/MyProfile/SignUp.png
Normal file
BIN
03_source/mobile/src/pages/MyProfile/SignUp.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 161 KiB |
@@ -44,7 +44,14 @@ import '../SpeakerList.scss';
|
|||||||
import { getEvents } from '../../api/getEvents';
|
import { getEvents } from '../../api/getEvents';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { Event } from './types';
|
import { Event } from './types';
|
||||||
import { alertOutline, chevronDownCircleOutline, createOutline, heart, menuOutline, settingsOutline } from 'ionicons/icons';
|
import {
|
||||||
|
alertOutline,
|
||||||
|
chevronDownCircleOutline,
|
||||||
|
createOutline,
|
||||||
|
heart,
|
||||||
|
menuOutline,
|
||||||
|
settingsOutline,
|
||||||
|
} from 'ionicons/icons';
|
||||||
import AboutPopover from '../../components/AboutPopover';
|
import AboutPopover from '../../components/AboutPopover';
|
||||||
import paths from '../../paths';
|
import paths from '../../paths';
|
||||||
import { getProfileById } from '../../api/getProfileById';
|
import { getProfileById } from '../../api/getProfileById';
|
||||||
@@ -54,6 +61,8 @@ import NotLoggedIn from './NotLoggedIn';
|
|||||||
interface OwnProps {}
|
interface OwnProps {}
|
||||||
|
|
||||||
interface StateProps {
|
interface StateProps {
|
||||||
|
isLoggedin: boolean;
|
||||||
|
//
|
||||||
speakers: Speaker[];
|
speakers: Speaker[];
|
||||||
speakerSessions: { [key: string]: Session[] };
|
speakerSessions: { [key: string]: Session[] };
|
||||||
}
|
}
|
||||||
@@ -62,7 +71,9 @@ interface DispatchProps {}
|
|||||||
|
|
||||||
interface SpeakerListProps extends OwnProps, StateProps, DispatchProps {}
|
interface SpeakerListProps extends OwnProps, StateProps, DispatchProps {}
|
||||||
|
|
||||||
const MyProfile: React.FC<SpeakerListProps> = ({ speakers, speakerSessions }) => {
|
const MyProfilePage: React.FC<SpeakerListProps> = ({ speakers, speakerSessions, isLoggedin }) => {
|
||||||
|
if (!isLoggedin) return <NotLoggedIn />;
|
||||||
|
|
||||||
const [profile, setProfile] = useState<Member>(defaultMember);
|
const [profile, setProfile] = useState<Member>(defaultMember);
|
||||||
|
|
||||||
const [showPopover, setShowPopover] = useState(false);
|
const [showPopover, setShowPopover] = useState(false);
|
||||||
@@ -94,7 +105,6 @@ const MyProfile: React.FC<SpeakerListProps> = ({ speakers, speakerSessions }) =>
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!profile) return <>loading</>;
|
if (!profile) return <>loading</>;
|
||||||
if (profile.id == -1) return <NotLoggedIn />;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IonPage id="speaker-list">
|
<IonPage id="speaker-list">
|
||||||
@@ -112,7 +122,12 @@ const MyProfile: React.FC<SpeakerListProps> = ({ speakers, speakerSessions }) =>
|
|||||||
|
|
||||||
<IonContent fullscreen={true}>
|
<IonContent fullscreen={true}>
|
||||||
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
|
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
|
||||||
<IonRefresherContent pullingIcon={chevronDownCircleOutline} pullingText="Pull to refresh" refreshingSpinner="circles" refreshingText="Refreshing..."></IonRefresherContent>
|
<IonRefresherContent
|
||||||
|
pullingIcon={chevronDownCircleOutline}
|
||||||
|
pullingText="Pull to refresh"
|
||||||
|
refreshingSpinner="circles"
|
||||||
|
refreshingText="Refreshing..."
|
||||||
|
></IonRefresherContent>
|
||||||
</IonRefresher>
|
</IonRefresher>
|
||||||
|
|
||||||
<IonHeader collapse="condense" className="ion-no-border">
|
<IonHeader collapse="condense" className="ion-no-border">
|
||||||
@@ -133,7 +148,10 @@ const MyProfile: React.FC<SpeakerListProps> = ({ speakers, speakerSessions }) =>
|
|||||||
>
|
>
|
||||||
<div style={{ padding: '1rem', display: 'flex', gap: '1rem' }}>
|
<div style={{ padding: '1rem', display: 'flex', gap: '1rem' }}>
|
||||||
<IonAvatar>
|
<IonAvatar>
|
||||||
<img alt="Silhouette of a person's head" src="https://plus.unsplash.com/premium_photo-1683121126477-17ef068309bc" />
|
<img
|
||||||
|
alt="Silhouette of a person's head"
|
||||||
|
src="https://plus.unsplash.com/premium_photo-1683121126477-17ef068309bc"
|
||||||
|
/>
|
||||||
</IonAvatar>
|
</IonAvatar>
|
||||||
<div style={{ flexGrow: 1 }}>
|
<div style={{ flexGrow: 1 }}>
|
||||||
<div
|
<div
|
||||||
@@ -150,7 +168,12 @@ const MyProfile: React.FC<SpeakerListProps> = ({ speakers, speakerSessions }) =>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<IonButton shape="round" fill="clear" size="large" onClick={handleNotImplementedClick}>
|
<IonButton
|
||||||
|
shape="round"
|
||||||
|
fill="clear"
|
||||||
|
size="large"
|
||||||
|
onClick={handleNotImplementedClick}
|
||||||
|
>
|
||||||
<IonIcon slot="icon-only" icon={createOutline}></IonIcon>
|
<IonIcon slot="icon-only" icon={createOutline}></IonIcon>
|
||||||
</IonButton>
|
</IonButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,7 +202,12 @@ const MyProfile: React.FC<SpeakerListProps> = ({ speakers, speakerSessions }) =>
|
|||||||
<div style={{ fontWeight: 'bold', fontSize: '1.1rem' }}>Membership</div>
|
<div style={{ fontWeight: 'bold', fontSize: '1.1rem' }}>Membership</div>
|
||||||
<div>7 of the exclusive privileges</div>
|
<div>7 of the exclusive privileges</div>
|
||||||
<div>
|
<div>
|
||||||
<IonButton expand="full" shape="round" size="large" onClick={handleNotImplementedClick}>
|
<IonButton
|
||||||
|
expand="full"
|
||||||
|
shape="round"
|
||||||
|
size="large"
|
||||||
|
onClick={handleNotImplementedClick}
|
||||||
|
>
|
||||||
Unlock
|
Unlock
|
||||||
</IonButton>
|
</IonButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -237,7 +265,12 @@ const MyProfile: React.FC<SpeakerListProps> = ({ speakers, speakerSessions }) =>
|
|||||||
</IonContent>
|
</IonContent>
|
||||||
|
|
||||||
{/* REQ0079/event-filter */}
|
{/* REQ0079/event-filter */}
|
||||||
<IonModal ref={modal} trigger="my-profile-open-modal" initialBreakpoint={0.5} breakpoints={[0, 0.25, 0.5, 0.75]}>
|
<IonModal
|
||||||
|
ref={modal}
|
||||||
|
trigger="my-profile-open-modal"
|
||||||
|
initialBreakpoint={0.5}
|
||||||
|
breakpoints={[0, 0.25, 0.5, 0.75]}
|
||||||
|
>
|
||||||
<IonContent className="ion-padding">
|
<IonContent className="ion-padding">
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -303,6 +336,7 @@ export default connect<OwnProps, StateProps, DispatchProps>({
|
|||||||
mapStateToProps: (state) => ({
|
mapStateToProps: (state) => ({
|
||||||
speakers: selectors.getSpeakers(state),
|
speakers: selectors.getSpeakers(state),
|
||||||
speakerSessions: selectors.getSpeakerSessions(state),
|
speakerSessions: selectors.getSpeakerSessions(state),
|
||||||
|
isLoggedin: state.user.isLoggedin,
|
||||||
}),
|
}),
|
||||||
component: React.memo(MyProfile),
|
component: React.memo(MyProfilePage),
|
||||||
});
|
});
|
||||||
|
@@ -63,6 +63,7 @@ import {
|
|||||||
import AboutPopover from '../../components/AboutPopover';
|
import AboutPopover from '../../components/AboutPopover';
|
||||||
import { OverlayEventDetail } from '@ionic/react/dist/types/components/react-component-lib/interfaces';
|
import { OverlayEventDetail } from '@ionic/react/dist/types/components/react-component-lib/interfaces';
|
||||||
import paths from '../../paths';
|
import paths from '../../paths';
|
||||||
|
import { logoutUser, setAccessToken, setIsLoggedIn } from '../../data/user/user.actions';
|
||||||
|
|
||||||
interface OwnProps {}
|
interface OwnProps {}
|
||||||
|
|
||||||
@@ -71,11 +72,21 @@ interface StateProps {
|
|||||||
speakerSessions: { [key: string]: Session[] };
|
speakerSessions: { [key: string]: Session[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DispatchProps {}
|
interface DispatchProps {
|
||||||
|
logoutUser: typeof logoutUser;
|
||||||
|
setAccessToken: typeof setAccessToken;
|
||||||
|
setIsLoggedIn: typeof setIsLoggedIn;
|
||||||
|
}
|
||||||
|
|
||||||
interface SpeakerListProps extends OwnProps, StateProps, DispatchProps {}
|
interface SpeakerListProps extends OwnProps, StateProps, DispatchProps {}
|
||||||
|
|
||||||
const EventList: React.FC<SpeakerListProps> = ({ speakers, speakerSessions }) => {
|
const EventList: React.FC<SpeakerListProps> = ({
|
||||||
|
speakers,
|
||||||
|
speakerSessions,
|
||||||
|
logoutUser,
|
||||||
|
setAccessToken,
|
||||||
|
setIsLoggedIn,
|
||||||
|
}) => {
|
||||||
const [events, setEvents] = useState<Event[] | []>([]);
|
const [events, setEvents] = useState<Event[] | []>([]);
|
||||||
const [showPopover, setShowPopover] = useState(false);
|
const [showPopover, setShowPopover] = useState(false);
|
||||||
const [popoverEvent, setPopoverEvent] = useState<MouseEvent>();
|
const [popoverEvent, setPopoverEvent] = useState<MouseEvent>();
|
||||||
@@ -116,6 +127,11 @@ const EventList: React.FC<SpeakerListProps> = ({ speakers, speakerSessions }) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleLogoutClick() {
|
function handleLogoutClick() {
|
||||||
|
setAccessToken();
|
||||||
|
setIsLoggedIn(false);
|
||||||
|
|
||||||
|
router.push('/tabs', 'forward', 'replace');
|
||||||
|
|
||||||
setShowLogoutConfirmModal(false);
|
setShowLogoutConfirmModal(false);
|
||||||
}
|
}
|
||||||
function handleLogoutCancel() {
|
function handleLogoutCancel() {
|
||||||
@@ -190,7 +206,11 @@ const EventList: React.FC<SpeakerListProps> = ({ speakers, speakerSessions }) =>
|
|||||||
</IonContent>
|
</IonContent>
|
||||||
|
|
||||||
{/* REQ0058/logout */}
|
{/* REQ0058/logout */}
|
||||||
<IonModal isOpen={showLogoutConfirmModal} initialBreakpoint={0.5} breakpoints={[0, 0.25, 0.5, 0.75]}>
|
<IonModal
|
||||||
|
isOpen={showLogoutConfirmModal}
|
||||||
|
initialBreakpoint={0.5}
|
||||||
|
breakpoints={[0, 0.25, 0.5, 0.75]}
|
||||||
|
>
|
||||||
<IonContent
|
<IonContent
|
||||||
className="ion-padding"
|
className="ion-padding"
|
||||||
style={{
|
style={{
|
||||||
@@ -251,5 +271,10 @@ export default connect<OwnProps, StateProps, DispatchProps>({
|
|||||||
speakers: selectors.getSpeakers(state),
|
speakers: selectors.getSpeakers(state),
|
||||||
speakerSessions: selectors.getSpeakerSessions(state),
|
speakerSessions: selectors.getSpeakerSessions(state),
|
||||||
}),
|
}),
|
||||||
|
mapDispatchToProps: {
|
||||||
|
logoutUser,
|
||||||
|
setAccessToken,
|
||||||
|
setIsLoggedIn,
|
||||||
|
},
|
||||||
component: React.memo(EventList),
|
component: React.memo(EventList),
|
||||||
});
|
});
|
||||||
|
Reference in New Issue
Block a user