Settings: Active Sessions 2.0 (#1843)

This commit is contained in:
Alexander Zinchuk 2022-04-26 17:08:47 +02:00
parent 67da677b1b
commit d7bcf7a769
16 changed files with 444 additions and 81 deletions

View File

@ -36,6 +36,7 @@ export function buildApiSession(session: GramJs.Authorization): ApiSession {
isOfficialApp: Boolean(session.officialApp),
isPasswordPending: Boolean(session.passwordPending),
hash: String(session.hash),
areCallsEnabled: !session.callRequestsDisabled,
...pick(session, [
'deviceModel', 'platform', 'systemVersion', 'appName', 'appVersion', 'dateCreated', 'dateActive',
'ip', 'country', 'region',

View File

@ -1,3 +1,4 @@
import BigInt from 'big-integer';
import {
ApiChat, ApiPhoto, ApiReportReason, ApiUser,
} from '../../types';
@ -41,3 +42,28 @@ export async function reportProfilePhoto({
return result;
}
export async function changeSessionSettings({
hash, areCallsEnabled,
}: {
hash: string; areCallsEnabled: boolean;
}) {
const result = await invokeRequest(new GramJs.account.ChangeAuthorizationSettings({
hash: BigInt(hash),
callRequestsDisabled: !areCallsEnabled,
}));
return result;
}
export async function changeSessionTtl({
days,
}: {
days: number;
}) {
const result = await invokeRequest(new GramJs.account.SetAuthorizationTTL({
authorizationTtlDays: days,
}));
return result;
}

View File

@ -3,7 +3,7 @@ export {
} from './client';
export {
reportPeer, reportProfilePhoto,
reportPeer, reportProfilePhoto, changeSessionSettings, changeSessionTtl,
} from './account';
export {

View File

@ -160,7 +160,10 @@ export async function fetchAuthorizations() {
return undefined;
}
return result.authorizations.map(buildApiSession);
return {
authorizations: buildCollectionByKey(result.authorizations.map(buildApiSession), 'hash'),
ttlDays: result.authorizationTtlDays,
};
}
export function terminateAuthorization(hash: string) {

View File

@ -61,6 +61,7 @@ export interface ApiSession {
ip: string;
country: string;
region: string;
areCallsEnabled: boolean;
}
export interface ApiSessionData {

View File

@ -0,0 +1,52 @@
$icons: "android", "apple", "brave", "chrome", "edge", "firefox", "linux", "opera", "safari", "samsung", "ubuntu", "unknown", "vivaldi", "windows", "xbox";
@mixin device-icon($icon-name) {
.iconDevice__#{$icon-name} {
background-image: url("../../../assets/devices/#{$icon-name}.svg");
}
}
.SettingsActiveSession {
:global(.modal-dialog) {
max-width: 28rem;
}
}
.iconDevice {
width: 5rem;
height: 5rem;
background-repeat: no-repeat;
background-size: 5rem;
margin: 0 auto 1rem;
}
@each $icon in $icons {
@include device-icon($icon);
}
.title {
text-align: center;
margin-bottom: 0.25rem;
}
.note,
.date {
color: var(--color-text-secondary);
font-size: 0.875rem;
text-align: center;
}
.box {
background: var(--color-background-secondary);
padding: 1rem 1rem 0.5rem;
border-radius: var(--border-radius-default);
margin: 1rem 0;
}
.actionHeader {
margin-top: 1px;
}
.actionName {
margin-right: auto;
}

View File

@ -0,0 +1,111 @@
import React, { FC, memo, useCallback } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import { ApiSession } from '../../../api/types';
import { formatDateTimeToString } from '../../../util/dateFormat';
import useLang from '../../../hooks/useLang';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
import getSessionIcon from './helpers/getSessionIcon';
import buildClassName from '../../../util/buildClassName';
import ListItem from '../../ui/ListItem';
import Modal from '../../ui/Modal';
import Switcher from '../../ui/Switcher';
import Button from '../../ui/Button';
import styles from './SettingsActiveSession.module.scss';
type OwnProps = {
isOpen: boolean;
hash?: string;
onClose: () => void;
};
type StateProps = {
session?: ApiSession;
};
const SettingsActiveSession: FC<OwnProps & StateProps> = ({
isOpen, session, onClose,
}) => {
const { changeSessionSettings, terminateAuthorization } = getActions();
const lang = useLang();
const renderingSession = useCurrentOrPrev(session, true);
const handleCallsStateChange = useCallback(() => {
changeSessionSettings({
hash: session!.hash,
areCallsEnabled: !session?.areCallsEnabled,
});
}, [changeSessionSettings, session]);
const handleTerminateSessionClick = useCallback(() => {
terminateAuthorization({ hash: session!.hash });
onClose();
}, [onClose, session, terminateAuthorization]);
if (!renderingSession) {
return undefined;
}
return (
<Modal
title={lang('SessionPreview.Title')}
isOpen={isOpen}
hasCloseButton
onClose={onClose}
className={styles.SettingsActiveSession}
>
<div className={buildClassName(
styles.iconDevice,
renderingSession && styles[`iconDevice__${getSessionIcon(renderingSession)}`],
)}
/>
<h3 className={styles.title} dir="auto">{renderingSession?.deviceModel}</h3>
<div className={styles.date} aria-label={lang('PrivacySettings.LastSeen')}>
{formatDateTimeToString(renderingSession.dateActive * 1000, lang.code)}
</div>
<dl className={styles.box}>
<dt>{lang('SessionPreview.App')}</dt>
<dd>
{renderingSession?.appName} {renderingSession?.appVersion},
{renderingSession?.platform} {renderingSession?.systemVersion}
</dd>
<dt>{lang('SessionPreview.Ip')}</dt>
<dd>{renderingSession?.ip}</dd>
<dt>{lang('SessionPreview.Location')}</dt>
<dd>{renderingSession && getLocation(renderingSession)}</dd>
</dl>
<p className={styles.note}>{lang('SessionPreview.IpDesc')}</p>
<h4 className={styles.actionHeader}>{lang('SessionPreview.AcceptHeader')}</h4>
<ListItem onClick={handleCallsStateChange}>
<span className={styles.actionName}>{lang('SessionPreview.Accept.Calls')}</span>
<Switcher
id="darkmode"
label="On"
checked={renderingSession.areCallsEnabled}
/>
</ListItem>
<Button color="danger" onClick={handleTerminateSessionClick}>{lang('SessionPreview.TerminateSession')}</Button>
</Modal>
);
};
function getLocation(session: ApiSession) {
return [session.region, session.country].filter(Boolean).join(', ');
}
export default memo(withGlobal<OwnProps>((global, { hash }) => {
return {
session: hash ? global.activeSessions.byHash[hash] : undefined,
};
})(SettingsActiveSession));

View File

@ -1,5 +1,6 @@
/* eslint-disable react/jsx-no-bind */
import React, {
FC, memo, useCallback, useMemo,
FC, memo, useCallback, useMemo, useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
@ -14,8 +15,10 @@ import getSessionIcon from './helpers/getSessionIcon';
import ListItem from '../../ui/ListItem';
import ConfirmDialog from '../../ui/ConfirmDialog';
import SettingsActiveSession from './SettingsActiveSession';
import './SettingsActiveSessions.scss';
import RadioGroup from '../../ui/RadioGroup';
type OwnProps = {
isActive?: boolean;
@ -24,21 +27,63 @@ type OwnProps = {
};
type StateProps = {
activeSessions: ApiSession[];
byHash: Record<string, ApiSession>;
orderedHashes: string[];
ttlDays?: number;
};
const SettingsActiveSessions: FC<OwnProps & StateProps> = ({
isActive,
onScreenSelect,
onReset,
activeSessions,
byHash,
orderedHashes,
ttlDays,
}) => {
const {
terminateAuthorization,
terminateAllAuthorizations,
changeSessionTtl,
} = getActions();
const lang = useLang();
const [isConfirmTerminateAllDialogOpen, openConfirmTerminateAllDialog, closeConfirmTerminateAllDialog] = useFlag();
const [openedSessionHash, setOpenedSessionHash] = useState<string | undefined>();
const [isModalOpen, openModal, closeModal] = useFlag();
const autoTerminateValue = useMemo(() => {
if (ttlDays === undefined) {
return undefined;
}
if (ttlDays <= 7) {
return '7';
}
if (ttlDays <= 30) {
return '30';
}
if (ttlDays <= 90) {
return '90';
}
if (ttlDays <= 180) {
return '180';
}
return undefined;
}, [ttlDays]);
const AUTO_TERMINATE_OPTIONS = useMemo(() => [{
label: lang('Weeks', 1, 'i'),
value: '7',
}, {
label: lang('Months', 1, 'i'),
value: '30',
}, {
label: lang('Months', 3, 'i'),
value: '90',
}, {
label: lang('Months', 6, 'i'),
value: '180',
}], [lang]);
const handleTerminateSessionClick = useCallback((hash: string) => {
terminateAuthorization({ hash });
@ -49,15 +94,30 @@ const SettingsActiveSessions: FC<OwnProps & StateProps> = ({
terminateAllAuthorizations();
}, [closeConfirmTerminateAllDialog, terminateAllAuthorizations]);
const handleOpenSessionModal = useCallback((hash: string) => {
setOpenedSessionHash(hash);
openModal();
}, [openModal]);
const handleCloseSessionModal = useCallback(() => {
setOpenedSessionHash(undefined);
closeModal();
}, [closeModal]);
const handleChangeSessionTtl = useCallback((value: string) => {
changeSessionTtl({ days: Number(value) });
}, [changeSessionTtl]);
const currentSession = useMemo(() => {
return activeSessions.find((session) => session.isCurrent);
}, [activeSessions]);
const currentSessionHash = orderedHashes.find((hash) => byHash[hash].isCurrent);
const otherSessions = useMemo(() => {
return activeSessions.filter((session) => !session.isCurrent);
}, [activeSessions]);
return currentSessionHash ? byHash[currentSessionHash] : undefined;
}, [byHash, orderedHashes]);
const lang = useLang();
const otherSessionHashes = useMemo(() => {
return orderedHashes.filter((hash) => !byHash[hash].isCurrent);
}, [byHash, orderedHashes]);
const hasOtherSessions = Boolean(otherSessionHashes.length);
useHistoryBack(isActive, onReset, onScreenSelect, SettingsScreens.ActiveSessions);
@ -78,32 +138,54 @@ const SettingsActiveSessions: FC<OwnProps & StateProps> = ({
</div>
</ListItem>
<ListItem
className="destructive mb-0 no-icon"
icon="stop"
ripple
narrow
onClick={openConfirmTerminateAllDialog}
>
{lang('TerminateAllSessions')}
</ListItem>
{hasOtherSessions && (
<ListItem
className="destructive mb-0 no-icon"
icon="stop"
ripple
narrow
onClick={openConfirmTerminateAllDialog}
>
{lang('TerminateAllSessions')}
</ListItem>
)}
</div>
);
}
function renderOtherSessions(sessions: ApiSession[]) {
function renderOtherSessions(sessionHashes: string[]) {
return (
<div className="settings-item">
<h4 className="settings-item-header mb-4" dir={lang.isRtl ? 'rtl' : undefined}>
{lang('OtherSessions')}
</h4>
{sessions.map(renderSession)}
{sessionHashes.map(renderSession)}
</div>
);
}
function renderSession(session: ApiSession) {
function renderAutoTerminate() {
return (
<div className="settings-item">
<h4 className="settings-item-header mb-4" dir={lang.isRtl ? 'rtl' : undefined}>
{lang('TerminateOldSessionHeader')}
</h4>
<p>{lang('IfInactiveFor')}</p>
<RadioGroup
name="session_ttl"
options={AUTO_TERMINATE_OPTIONS}
selected={autoTerminateValue}
onChange={handleChangeSessionTtl}
/>
</div>
);
}
function renderSession(sessionHash: string) {
const session = byHash[sessionHash];
return (
<ListItem
key={session.hash}
@ -117,6 +199,7 @@ const SettingsActiveSessions: FC<OwnProps & StateProps> = ({
},
}]}
icon={`device-${getSessionIcon(session)} icon-device`}
onClick={() => { handleOpenSessionModal(session.hash); }}
>
<div className="multiline-menu-item full-size" dir="auto">
<span className="date">{formatPastTimeShort(lang, session.dateActive * 1000)}</span>
@ -133,17 +216,19 @@ const SettingsActiveSessions: FC<OwnProps & StateProps> = ({
return (
<div className="settings-content custom-scroll SettingsActiveSessions">
{currentSession && renderCurrentSession(currentSession)}
{otherSessions && renderOtherSessions(otherSessions)}
{otherSessions && (
{hasOtherSessions && renderOtherSessions(otherSessionHashes)}
{renderAutoTerminate()}
{hasOtherSessions && (
<ConfirmDialog
isOpen={isConfirmTerminateAllDialogOpen}
onClose={closeConfirmTerminateAllDialog}
text="Are you sure you want to terminate all other sessions?"
confirmLabel="Terminate All Other Sessions"
text={lang('AreYouSureSessions')}
confirmLabel={lang('TerminateAllSessions')}
confirmHandler={handleTerminateAllSessions}
confirmIsDestructive
/>
)}
<SettingsActiveSession isOpen={isModalOpen} hash={openedSessionHash} onClose={handleCloseSessionModal} />
</div>
);
};
@ -153,9 +238,5 @@ function getLocation(session: ApiSession) {
}
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
return {
activeSessions: global.activeSessions,
};
},
(global): StateProps => global.activeSessions,
)(SettingsActiveSessions));

View File

@ -127,7 +127,7 @@ export default memo(withGlobal<OwnProps>(
const { currentUserId, lastSyncTime } = global;
return {
sessionCount: global.activeSessions.length,
sessionCount: global.activeSessions.orderedHashes.length,
currentUser: currentUserId ? selectUser(global, currentUserId) : undefined,
lastSyncTime,
};

View File

@ -1,4 +1,4 @@
import { addActionHandler } from '../../index';
import { addActionHandler, getGlobal, setGlobal } from '../../index';
import { selectChat } from '../../selectors';
import { callApi } from '../../../api/gramjs';
import { getTranslation } from '../../../util/langProvider';
@ -60,3 +60,111 @@ addActionHandler('reportProfilePhoto', async (global, actions, payload) => {
: 'An error occurred while submitting your report. Please, try again later.',
});
});
addActionHandler('loadAuthorizations', async () => {
const result = await callApi('fetchAuthorizations');
if (!result) {
return;
}
setGlobal({
...getGlobal(),
activeSessions: {
byHash: result.authorizations,
orderedHashes: Object.keys(result.authorizations),
ttlDays: result.ttlDays,
},
});
});
addActionHandler('terminateAuthorization', async (global, actions, payload) => {
const { hash } = payload!;
const result = await callApi('terminateAuthorization', hash);
if (!result) {
return;
}
global = getGlobal();
const { [hash]: removedSessions, ...newSessions } = global.activeSessions.byHash;
setGlobal({
...global,
activeSessions: {
byHash: newSessions,
orderedHashes: global.activeSessions.orderedHashes.filter((el) => el !== hash),
},
});
});
addActionHandler('terminateAllAuthorizations', async (global) => {
const result = await callApi('terminateAllAuthorizations');
if (!result) {
return;
}
global = getGlobal();
const currentSessionHash = global.activeSessions.orderedHashes
.find((hash) => global.activeSessions.byHash[hash].isCurrent);
if (!currentSessionHash) {
return;
}
const currentSession = global.activeSessions.byHash[currentSessionHash];
setGlobal({
...global,
activeSessions: {
byHash: {
[currentSessionHash]: currentSession,
},
orderedHashes: [currentSessionHash],
},
});
});
addActionHandler('changeSessionSettings', async (global, actions, payload) => {
const { hash, areCallsEnabled } = payload;
const result = await callApi('changeSessionSettings', {
hash,
areCallsEnabled,
});
if (!result) {
return;
}
global = getGlobal();
setGlobal({
...global,
activeSessions: {
...global.activeSessions,
byHash: {
...global.activeSessions.byHash,
[hash]: {
...global.activeSessions.byHash[hash],
areCallsEnabled,
},
},
},
});
});
addActionHandler('changeSessionTtl', async (global, actions, payload) => {
const { days } = payload;
const result = await callApi('changeSessionTtl', { days });
if (!result) {
return;
}
global = getGlobal();
setGlobal({
...global,
activeSessions: {
...global.activeSessions,
ttlDays: days,
},
});
});

View File

@ -241,48 +241,6 @@ addActionHandler('unblockContact', async (global, actions, payload) => {
setGlobal(removeBlockedContact(getGlobal(), contactId));
});
addActionHandler('loadAuthorizations', async () => {
const result = await callApi('fetchAuthorizations');
if (!result) {
return;
}
setGlobal({
...getGlobal(),
activeSessions: result,
});
});
addActionHandler('terminateAuthorization', async (global, actions, payload) => {
const { hash } = payload!;
const result = await callApi('terminateAuthorization', hash);
if (!result) {
return;
}
global = getGlobal();
setGlobal({
...global,
activeSessions: global.activeSessions.filter((session) => session.hash !== hash),
});
});
addActionHandler('terminateAllAuthorizations', async (global) => {
const result = await callApi('terminateAllAuthorizations');
if (!result) {
return;
}
global = getGlobal();
setGlobal({
...global,
activeSessions: global.activeSessions.filter((session) => session.isCurrent),
});
});
addActionHandler('loadNotificationExceptions', async (global) => {
const { serverTimeOffset } = global;

View File

@ -235,6 +235,13 @@ function migrateCache(cached: GlobalState, initialState: GlobalState) {
if (!cached.trustedBotIds) {
cached.trustedBotIds = [];
}
if (cached.activeSessions?.byHash === undefined) {
cached.activeSessions = {
byHash: {},
orderedHashes: [],
};
}
}
function updateCache() {

View File

@ -138,7 +138,10 @@ export const INITIAL_STATE: GlobalState = {
dialogs: [],
activeSessions: [],
activeSessions: {
byHash: {},
orderedHashes: [],
},
settings: {
byKey: {

View File

@ -482,8 +482,11 @@ export type GlobalState = {
notifications: ApiNotification[];
dialogs: (ApiError | ApiInviteInfo)[];
// TODO Move to settings
activeSessions: ApiSession[];
activeSessions: {
byHash: Record<string, ApiSession>;
orderedHashes: string[];
ttlDays?: number;
};
settings: {
byKey: ISettings;
@ -598,6 +601,13 @@ export interface ActionPayloads {
description: string;
photo?: ApiPhoto;
};
changeSessionSettings: {
hash: string;
areCallsEnabled: boolean;
};
changeSessionTtl: {
days: number;
};
// Chats
openChat: {
@ -700,7 +710,6 @@ export interface ActionPayloads {
};
// Bots
clickBotInlineButton: {
messageId: number;
button: ApiKeyboardButton;
@ -778,7 +787,6 @@ export interface ActionPayloads {
};
// Misc
openPollModal: {
isQuiz?: boolean;
};

View File

@ -1032,6 +1032,8 @@ account.uploadWallPaper#dd853661 file:InputFile mime_type:string settings:WallPa
account.setContentSettings#b574b16b flags:# sensitive_enabled:flags.0?true = Bool;
account.getContentSettings#8b9b4dae = account.ContentSettings;
account.reportProfilePhoto#fa8cc6f5 peer:InputPeer photo_id:InputPhoto reason:ReportReason message:string = Bool;
account.setAuthorizationTTL#bf899aa0 authorization_ttl_days:int = Bool;
account.changeAuthorizationSettings#40f48462 flags:# hash:long encrypted_requests_disabled:flags.0?Bool call_requests_disabled:flags.1?Bool = Bool;
users.getUsers#d91a548 id:Vector<InputUser> = Vector<User>;
users.getFullUser#b60f5918 id:InputUser = users.UserFull;
contacts.getContacts#5dd69e12 hash:long = contacts.Contacts;

View File

@ -45,6 +45,8 @@
"account.getContentSettings",
"account.reportPeer",
"account.reportProfilePhoto",
"account.changeAuthorizationSettings",
"account.setAuthorizationTTL",
"users.getUsers",
"users.getFullUser",
"contacts.getContacts",