diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index 514875293..8f9c21c0e 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -17,6 +17,9 @@ type GramJsAppConfig = { reactions_uniq_max: number; chat_read_mark_size_threshold: number; chat_read_mark_expire_period: number; + autologin_domains: string[]; + autologin_token: string; + url_auth_domains: string[]; }; function buildEmojiSounds(appConfig: GramJsAppConfig) { @@ -38,7 +41,7 @@ function buildEmojiSounds(appConfig: GramJsAppConfig) { }, {}) : {}; } -export function buildApiConfig(json: GramJs.TypeJSONValue): ApiAppConfig { +export function buildAppConfig(json: GramJs.TypeJSONValue): ApiAppConfig { const appConfig = buildJson(json) as GramJsAppConfig; return { @@ -46,5 +49,8 @@ export function buildApiConfig(json: GramJs.TypeJSONValue): ApiAppConfig { defaultReaction: appConfig.reactions_default, seenByMaxChatMembers: appConfig.chat_read_mark_size_threshold, seenByExpiresAt: appConfig.chat_read_mark_expire_period, + autologinDomains: appConfig.autologin_domains || [], + autologinToken: appConfig.autologin_token || '', + urlAuthDomains: appConfig.url_auth_domains || [], }; } diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 15eff055b..0037f710e 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -1104,6 +1104,15 @@ function buildReplyButtons(message: UniversalMessage): ApiReplyKeyboard | undefi }; } + if (button instanceof GramJs.KeyboardButtonUrlAuth) { + return { + type: 'urlAuth', + text, + url: button.url, + buttonId: button.buttonId, + }; + } + return { type: 'unsupported', text, diff --git a/src/api/gramjs/apiBuilders/misc.ts b/src/api/gramjs/apiBuilders/misc.ts index 35c48e6a6..2f7eac6d6 100644 --- a/src/api/gramjs/apiBuilders/misc.ts +++ b/src/api/gramjs/apiBuilders/misc.ts @@ -1,7 +1,7 @@ import { Api as GramJs } from '../../../lib/gramjs'; import type { - ApiCountry, ApiSession, ApiWallpaper, + ApiCountry, ApiSession, ApiUrlAuthResult, ApiWallpaper, ApiWebSession, } from '../../types'; import type { ApiPrivacySettings, ApiPrivacyKey, PrivacyVisibility } from '../../../types'; @@ -9,6 +9,8 @@ import { buildApiDocument } from './messages'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers'; import { pick } from '../../../util/iteratees'; import { getServerTime } from '../../../util/serverTime'; +import { buildApiUser } from './users'; +import { addUserToLocalDb } from '../helpers'; export function buildApiWallpaper(wallpaper: GramJs.TypeWallPaper): ApiWallpaper | undefined { if (wallpaper instanceof GramJs.WallPaperNoFile) { @@ -45,6 +47,16 @@ export function buildApiSession(session: GramJs.Authorization): ApiSession { }; } +export function buildApiWebSession(session: GramJs.WebAuthorization): ApiWebSession { + return { + hash: String(session.hash), + botId: buildApiPeerId(session.botId, 'user'), + ...pick(session, [ + 'platform', 'browser', 'dateCreated', 'dateActive', 'ip', 'region', 'domain', + ]), + }; +} + export function buildPrivacyKey(key: GramJs.TypePrivacyKey): ApiPrivacyKey | undefined { switch (key.className) { case 'PrivacyKeyPhoneNumber': @@ -176,3 +188,34 @@ export function buildJson(json: GramJs.TypeJSONValue): any { return acc; }, {}); } + +export function buildApiUrlAuthResult(result: GramJs.TypeUrlAuthResult): ApiUrlAuthResult | undefined { + if (result instanceof GramJs.UrlAuthResultRequest) { + const { bot, domain, requestWriteAccess } = result; + const user = buildApiUser(bot); + if (!user) return undefined; + + addUserToLocalDb(bot); + + return { + type: 'request', + domain, + shouldRequestWriteAccess: requestWriteAccess, + bot: user, + }; + } + + if (result instanceof GramJs.UrlAuthResultAccepted) { + return { + type: 'accepted', + url: result.url, + }; + } + + if (result instanceof GramJs.UrlAuthResultDefault) { + return { + type: 'default', + }; + } + return undefined; +} diff --git a/src/api/gramjs/methods/bots.ts b/src/api/gramjs/methods/bots.ts index f7b7f836f..11adfcbd6 100644 --- a/src/api/gramjs/methods/bots.ts +++ b/src/api/gramjs/methods/bots.ts @@ -1,7 +1,9 @@ import BigInt from 'big-integer'; import { Api as GramJs } from '../../../lib/gramjs'; -import type { ApiChat, ApiThemeParameters, ApiUser } from '../../types'; +import type { + ApiChat, ApiThemeParameters, ApiUser, OnApiUpdate, +} from '../../types'; import localDb from '../localDb'; import { invokeRequest } from './client'; @@ -14,8 +16,12 @@ import { buildApiChatFromPreview } from '../apiBuilders/chats'; import { addEntitiesWithPhotosToLocalDb, addUserToLocalDb, deserializeBytes } from '../helpers'; import { omitVirtualClassFields } from '../apiBuilders/helpers'; import { buildCollectionByKey } from '../../../util/iteratees'; +import { buildApiUrlAuthResult } from '../apiBuilders/misc'; -export function init() { +let onUpdate: OnApiUpdate; + +export function init(_onUpdate: OnApiUpdate) { + onUpdate = _onUpdate; } export async function answerCallbackButton({ @@ -269,6 +275,100 @@ export function toggleBotInAttachMenu({ })); } +export async function requestBotUrlAuth({ + chat, buttonId, messageId, +}: { + chat: ApiChat; + buttonId: number; + messageId: number; +}) { + const result = await invokeRequest(new GramJs.messages.RequestUrlAuth({ + peer: buildInputPeer(chat.id, chat.accessHash), + buttonId, + msgId: messageId, + })); + + if (!result) return undefined; + + const authResult = buildApiUrlAuthResult(result); + if (authResult?.type === 'request') { + onUpdate({ + '@type': 'updateUser', + id: authResult.bot.id, + user: authResult.bot, + }); + } + return authResult; +} + +export async function acceptBotUrlAuth({ + chat, + messageId, + buttonId, + isWriteAllowed, +}: { + chat: ApiChat; + messageId: number; + buttonId: number; + isWriteAllowed?: boolean; +}) { + const result = await invokeRequest(new GramJs.messages.AcceptUrlAuth({ + peer: buildInputPeer(chat.id, chat.accessHash), + msgId: messageId, + buttonId, + writeAllowed: isWriteAllowed || undefined, + })); + + if (!result) return undefined; + + const authResult = buildApiUrlAuthResult(result); + if (authResult?.type === 'request') { + onUpdate({ + '@type': 'updateUser', + id: authResult.bot.id, + user: authResult.bot, + }); + } + return authResult; +} + +export async function requestLinkUrlAuth({ url }: { url: string }) { + const result = await invokeRequest(new GramJs.messages.RequestUrlAuth({ + url, + })); + + if (!result) return undefined; + + const authResult = buildApiUrlAuthResult(result); + if (authResult?.type === 'request') { + onUpdate({ + '@type': 'updateUser', + id: authResult.bot.id, + user: authResult.bot, + }); + } + return authResult; +} + +export async function acceptLinkUrlAuth({ url, isWriteAllowed }: { url: string; isWriteAllowed?: boolean }) { + const result = await invokeRequest(new GramJs.messages.AcceptUrlAuth({ + url, + writeAllowed: isWriteAllowed || undefined, + })); + + if (!result) return undefined; + + const authResult = buildApiUrlAuthResult(result); + if (authResult?.type === 'request') { + onUpdate({ + '@type': 'updateUser', + id: authResult.bot.id, + user: authResult.bot, + }); + } + return authResult; +} + function processInlineBotResult(queryId: string, results: GramJs.TypeBotInlineResult[]) { return results.map((result) => { if (result instanceof GramJs.BotInlineMediaResult) { diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 398623d99..115371ca6 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -54,6 +54,7 @@ export { updateProfile, checkUsername, updateUsername, fetchBlockedContacts, blockContact, unblockContact, updateProfilePhoto, uploadProfilePhoto, fetchWallpapers, uploadWallpaper, fetchAuthorizations, terminateAuthorization, terminateAllAuthorizations, + fetchWebAuthorizations, terminateWebAuthorization, terminateAllWebAuthorizations, fetchNotificationExceptions, fetchNotificationSettings, updateContactSignUpNotification, updateNotificationSettings, fetchLanguages, fetchLangPack, fetchPrivacySettings, setPrivacySettings, registerDevice, unregisterDevice, updateIsOnline, fetchContentSettings, updateContentSettings, fetchLangStrings, fetchCountryList, fetchAppConfig, @@ -66,6 +67,7 @@ export { export { answerCallbackButton, fetchTopInlineBots, fetchInlineBot, fetchInlineBotResults, sendInlineBotResult, startBot, requestWebView, requestSimpleWebView, sendWebViewData, prolongWebView, loadAttachMenuBots, toggleBotInAttachMenu, + requestBotUrlAuth, requestLinkUrlAuth, acceptBotUrlAuth, acceptLinkUrlAuth, } from './bots'; export { @@ -86,7 +88,7 @@ export { export { fetchChannelStatistics, fetchGroupStatistics, fetchMessageStatistics, - fetchMessagePublicForwards, fetchStatisticsAsyncGraph, + fetchMessagePublicForwards, fetchStatisticsAsyncGraph, } from './statistics'; export { diff --git a/src/api/gramjs/methods/settings.ts b/src/api/gramjs/methods/settings.ts index f8200280c..38e1cb1a5 100644 --- a/src/api/gramjs/methods/settings.ts +++ b/src/api/gramjs/methods/settings.ts @@ -19,20 +19,21 @@ import { buildApiNotifyException, buildApiSession, buildApiWallpaper, + buildApiWebSession, buildPrivacyRules, } from '../apiBuilders/misc'; import { buildApiUser } from '../apiBuilders/users'; import { buildApiChatFromPreview } from '../apiBuilders/chats'; +import { getApiChatIdFromMtpPeer } from '../apiBuilders/peers'; +import { buildAppConfig } from '../apiBuilders/appConfig'; +import { omitVirtualClassFields } from '../apiBuilders/helpers'; import { buildInputEntity, buildInputPeer, buildInputPrivacyKey } from '../gramjsBuilders'; import { getClient, invokeRequest, uploadFile } from './client'; -import { omitVirtualClassFields } from '../apiBuilders/helpers'; import { buildCollectionByKey } from '../../../util/iteratees'; import { getServerTime } from '../../../util/serverTime'; -import { getApiChatIdFromMtpPeer } from '../apiBuilders/peers'; -import localDb from '../localDb'; -import { buildApiConfig } from '../apiBuilders/appConfig'; import { addEntitiesWithPhotosToLocalDb } from '../helpers'; +import localDb from '../localDb'; const MAX_INT_32 = 2 ** 31 - 1; const BETA_LANG_CODES = ['ar', 'fa', 'id', 'ko', 'uz', 'en']; @@ -175,6 +176,23 @@ export function terminateAllAuthorizations() { return invokeRequest(new GramJs.auth.ResetAuthorizations()); } +export async function fetchWebAuthorizations() { + const result = await invokeRequest(new GramJs.account.GetWebAuthorizations()); + if (!result) { + return undefined; + } + + return buildCollectionByKey(result.authorizations.map(buildApiWebSession), 'hash'); +} + +export function terminateWebAuthorization(hash: string) { + return invokeRequest(new GramJs.account.ResetWebAuthorization({ hash: BigInt(hash) })); +} + +export function terminateAllWebAuthorizations() { + return invokeRequest(new GramJs.account.ResetWebAuthorizations()); +} + export async function fetchNotificationExceptions({ serverTimeOffset, }: { serverTimeOffset: number }) { @@ -451,7 +469,7 @@ export async function fetchAppConfig(): Promise { const result = await invokeRequest(new GramJs.help.GetAppConfig()); if (!result) return undefined; - return buildApiConfig(result); + return buildAppConfig(result); } function updateLocalDb( diff --git a/src/api/gramjs/provider.ts b/src/api/gramjs/provider.ts index 08cae2967..231714782 100644 --- a/src/api/gramjs/provider.ts +++ b/src/api/gramjs/provider.ts @@ -17,6 +17,7 @@ import { init as initClient } from './methods/client'; import { init as initStickers } from './methods/symbols'; import { init as initManagement } from './methods/management'; import { init as initTwoFaSettings } from './methods/twoFaSettings'; +import { init as initBots } from './methods/bots'; import { init as initCalls } from './methods/calls'; import { init as initPayments } from './methods/payments'; import * as methods from './methods'; @@ -34,6 +35,7 @@ export async function initApi(_onUpdate: OnApiUpdate, initialArgs: ApiInitialArg initStickers(handleUpdate); initManagement(handleUpdate); initTwoFaSettings(handleUpdate); + initBots(handleUpdate); initCalls(handleUpdate); initPayments(handleUpdate); diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 921c0ffb5..05d759c4b 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -438,6 +438,13 @@ interface ApiKeyboardButtonUserProfile { userId: string; } +interface ApiKeyboardButtonUrlAuth { + type: 'urlAuth'; + text: string; + url: string; + buttonId: number; +} + export type ApiKeyboardButton = ( ApiKeyboardButtonSimple | ApiKeyboardButtonReceipt @@ -448,6 +455,7 @@ export type ApiKeyboardButton = ( | ApiKeyboardButtonUserProfile | ApiKeyboardButtonWebView | ApiKeyboardButtonSimpleWebView + | ApiKeyboardButtonUrlAuth ); export type ApiKeyboardButtons = ApiKeyboardButton[][]; diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 0edc5441c..345f7d4c4 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -1,4 +1,5 @@ import type { ApiDocument, ApiPhoto } from './messages'; +import type { ApiUser } from './users'; export interface ApiInitialArgs { userAgent: string; @@ -65,6 +66,18 @@ export interface ApiSession { areSecretChatsEnabled: boolean; } +export interface ApiWebSession { + hash: string; + botId: string; + domain: string; + browser: string; + platform: string; + dateCreated: number; + dateActive: number; + ip: string; + region: string; +} + export interface ApiSessionData { mainDcId: number; keys: Record; @@ -145,6 +158,9 @@ export interface ApiAppConfig { defaultReaction: string; seenByMaxChatMembers: number; seenByExpiresAt: number; + autologinDomains: string[]; + autologinToken: string; + urlAuthDomains: string[]; } export interface GramJsEmojiInteraction { @@ -158,3 +174,21 @@ export interface GramJsEmojiInteraction { export interface ApiEmojiInteraction { timestamps: number[]; } + +type ApiUrlAuthResultRequest = { + type: 'request'; + bot: ApiUser; + domain: string; + shouldRequestWriteAccess?: boolean; +}; + +type ApiUrlAuthResultAccepted = { + type: 'accepted'; + url: string; +}; + +type ApiUrlAuthResultDefault = { + type: 'default'; +}; + +export type ApiUrlAuthResult = ApiUrlAuthResultRequest | ApiUrlAuthResultAccepted | ApiUrlAuthResultDefault; diff --git a/src/api/types/statistics.ts b/src/api/types/statistics.ts index e989e966a..3da5c8cf0 100644 --- a/src/api/types/statistics.ts +++ b/src/api/types/statistics.ts @@ -1,5 +1,5 @@ import type { ApiChat } from './chats'; -import type { ApiMessage, ApiPhoto } from './messages'; +import type { ApiMessage } from './messages'; export interface ApiChannelStatistics { growthGraph?: StatisticsGraph | string; diff --git a/src/assets/fonts/icomoon.woff b/src/assets/fonts/icomoon.woff index 524f03810..ff124f775 100644 Binary files a/src/assets/fonts/icomoon.woff and b/src/assets/fonts/icomoon.woff differ diff --git a/src/assets/fonts/icomoon.woff2 b/src/assets/fonts/icomoon.woff2 index 30e6da39d..7b711129f 100644 Binary files a/src/assets/fonts/icomoon.woff2 and b/src/assets/fonts/icomoon.woff2 differ diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index d0b595c4f..0bbfa2cf8 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -5,6 +5,7 @@ export { default as ForwardPicker } from '../components/main/ForwardPicker'; export { default as Dialogs } from '../components/main/Dialogs'; export { default as Notifications } from '../components/main/Notifications'; export { default as SafeLinkModal } from '../components/main/SafeLinkModal'; +export { default as UrlAuthModal } from '../components/main/UrlAuthModal'; export { default as HistoryCalendar } from '../components/main/HistoryCalendar'; export { default as NewContactModal } from '../components/main/NewContactModal'; export { default as WebAppModal } from '../components/main/WebAppModal'; diff --git a/src/components/calls/phone/RatePhoneCallModal.tsx b/src/components/calls/phone/RatePhoneCallModal.tsx index 80a9da9d4..2165ffe7e 100644 --- a/src/components/calls/phone/RatePhoneCallModal.tsx +++ b/src/components/calls/phone/RatePhoneCallModal.tsx @@ -1,7 +1,10 @@ -import type { FC } from '../../../lib/teact/teact'; -import React, { memo, useRef, useState } from '../../../lib/teact/teact'; +import React, { + memo, useRef, useState, useCallback, +} from '../../../lib/teact/teact'; import { getActions } from '../../../global'; +import type { FC } from '../../../lib/teact/teact'; + import useLang from '../../../hooks/useLang'; import buildClassName from '../../../util/buildClassName'; @@ -41,6 +44,10 @@ const RatePhoneCallModal: FC = ({ return () => setRating(rating === index ? undefined : index); } + const handleCancelClick = useCallback(() => { + closeCallRatingModal(); + }, [closeCallRatingModal]); + return (
@@ -68,7 +75,7 @@ const RatePhoneCallModal: FC = ({ - + ); }; diff --git a/src/components/common/SafeLink.tsx b/src/components/common/SafeLink.tsx index a301c6911..5a9c44685 100644 --- a/src/components/common/SafeLink.tsx +++ b/src/components/common/SafeLink.tsx @@ -4,7 +4,7 @@ import { getActions } from '../../global'; import convertPunycode from '../../lib/punycode'; import { - DEBUG, RE_TG_LINK, RE_TME_LINK, + DEBUG, } from '../../config'; import buildClassName from '../../util/buildClassName'; import { ensureProtocol } from '../../util/ensureProtocol'; @@ -24,31 +24,18 @@ const SafeLink: FC = ({ children, isRtl, }) => { - const { toggleSafeLinkModal, openTelegramLink } = getActions(); + const { openUrl } = getActions(); const content = children || text; - const isNotSafe = url !== content; + const isSafe = url === content; const handleClick = useCallback((e: React.MouseEvent) => { - if ( - e.ctrlKey || e.altKey || e.shiftKey || e.metaKey - || !url || (!url.match(RE_TME_LINK) && !url.match(RE_TG_LINK)) - ) { - if (isNotSafe) { - toggleSafeLinkModal({ url }); - - e.preventDefault(); - return false; - } - - return true; - } - + if (!url) return true; e.preventDefault(); - openTelegramLink({ url }); + openUrl({ url, shouldSkipModal: isSafe }); return false; - }, [isNotSafe, openTelegramLink, toggleSafeLinkModal, url]); + }, [isSafe, openUrl, url]); if (!url) { return undefined; diff --git a/src/components/left/LeftColumn.tsx b/src/components/left/LeftColumn.tsx index 872edc766..7bb24ec1d 100644 --- a/src/components/left/LeftColumn.tsx +++ b/src/components/left/LeftColumn.tsx @@ -161,6 +161,7 @@ const LeftColumn: FC = ({ case SettingsScreens.PrivacyForwarding: case SettingsScreens.PrivacyGroupChats: case SettingsScreens.PrivacyBlockedUsers: + case SettingsScreens.ActiveWebsites: case SettingsScreens.TwoFaDisabled: case SettingsScreens.TwoFaEnabled: case SettingsScreens.TwoFaCongratulations: diff --git a/src/components/left/main/LeftMainHeader.tsx b/src/components/left/main/LeftMainHeader.tsx index 7be57556e..e2b007ae8 100644 --- a/src/components/left/main/LeftMainHeader.tsx +++ b/src/components/left/main/LeftMainHeader.tsx @@ -208,7 +208,7 @@ const LeftMainHeader: FC = ({ }, [animationLevel, setSettingOption, theme]); const handleChangelogClick = useCallback(() => { - window.open(BETA_CHANGELOG_URL, '_blank'); + window.open(BETA_CHANGELOG_URL, '_blank', 'noopener'); }, []); const handleRuDiscussionClick = useCallback(() => { diff --git a/src/components/left/settings/Settings.scss b/src/components/left/settings/Settings.scss index 01423a0fd..54f2fb9f8 100644 --- a/src/components/left/settings/Settings.scss +++ b/src/components/left/settings/Settings.scss @@ -182,6 +182,7 @@ &.full-size { width: 100%; + overflow: hidden; } .date { diff --git a/src/components/left/settings/Settings.tsx b/src/components/left/settings/Settings.tsx index bb788d42e..6be8f2bfa 100644 --- a/src/components/left/settings/Settings.tsx +++ b/src/components/left/settings/Settings.tsx @@ -21,6 +21,7 @@ import SettingsPrivacy from './SettingsPrivacy'; import SettingsLanguage from './SettingsLanguage'; import SettingsPrivacyVisibility from './SettingsPrivacyVisibility'; import SettingsActiveSessions from './SettingsActiveSessions'; +import SettingsActiveWebsites from './SettingsActiveWebsites'; import SettingsPrivacyBlockedUsers from './SettingsPrivacyBlockedUsers'; import SettingsTwoFa from './twoFa/SettingsTwoFa'; import SettingsPrivacyVisibilityExceptionList from './SettingsPrivacyVisibilityExceptionList'; @@ -69,7 +70,7 @@ const FOLDERS_SCREENS = [ const PRIVACY_SCREENS = [ SettingsScreens.PrivacyBlockedUsers, - SettingsScreens.ActiveSessions, + SettingsScreens.ActiveWebsites, ]; const PRIVACY_PHONE_NUMBER_SCREENS = [ @@ -258,6 +259,13 @@ const Settings: FC = ({ onReset={handleReset} /> ); + case SettingsScreens.ActiveWebsites: + return ( + + ); case SettingsScreens.PrivacyBlockedUsers: return ( = ({ contextActions={[{ title: 'Terminate', icon: 'stop', + destructive: true, handler: () => { handleTerminateSessionClick(session.hash); }, diff --git a/src/components/left/settings/SettingsActiveWebsite.module.scss b/src/components/left/settings/SettingsActiveWebsite.module.scss new file mode 100644 index 000000000..cf5796aa3 --- /dev/null +++ b/src/components/left/settings/SettingsActiveWebsite.module.scss @@ -0,0 +1,48 @@ +.root { + :global(.modal-dialog) { + max-width: 28rem; + } +} + +.avatar { + width: 5rem; + height: 5rem; + font-size: 3.5rem; + margin: 0 auto 1rem; + border-radius: 1rem; + + :global(.Avatar__img) { + border-radius: 1rem; + } +} + +.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; +} + +.action-header { + margin-top: 1px; +} + +.action-name { + margin-right: auto; +} + +.header-button { + margin-right: -0.5rem; +} diff --git a/src/components/left/settings/SettingsActiveWebsite.tsx b/src/components/left/settings/SettingsActiveWebsite.tsx new file mode 100644 index 000000000..1cd74c887 --- /dev/null +++ b/src/components/left/settings/SettingsActiveWebsite.tsx @@ -0,0 +1,103 @@ +import React, { memo, useCallback } from '../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../global'; + +import type { FC } from '../../../lib/teact/teact'; +import type { ApiUser, ApiWebSession } from '../../../api/types'; + +import { getUserFullName } from '../../../global/helpers'; +import buildClassName from '../../../util/buildClassName'; + +import useLang from '../../../hooks/useLang'; +import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev'; + +import Modal from '../../ui/Modal'; +import Button from '../../ui/Button'; +import Avatar from '../../common/Avatar'; + +import styles from './SettingsActiveWebsite.module.scss'; + +type OwnProps = { + isOpen: boolean; + hash?: string; + onClose: () => void; +}; + +type StateProps = { + session?: ApiWebSession; + bot?: ApiUser; +}; + +const SettingsActiveWebsite: FC = ({ + isOpen, session, bot, onClose, +}) => { + const { terminateWebAuthorization } = getActions(); + const lang = useLang(); + + const renderingSession = useCurrentOrPrev(session, true); + const renderingBot = useCurrentOrPrev(bot, true); + + const handleTerminateSessionClick = useCallback(() => { + terminateWebAuthorization({ hash: session!.hash }); + onClose(); + }, [onClose, session, terminateWebAuthorization]); + + if (!renderingSession) { + return undefined; + } + + function renderHeader() { + return ( +
+ +
{lang('WebSessionsTitle')}
+ +
+ ); + } + return ( + + +

{getUserFullName(renderingBot)}

+
+ {renderingSession?.domain} +
+ +
+
{lang('AuthSessions.View.Browser')}
+
+ {renderingSession?.browser} +
+ +
{lang('SessionPreview.Ip')}
+
{renderingSession?.ip}
+ +
{lang('SessionPreview.Location')}
+
{renderingSession?.region}
+
+

{lang('AuthSessions.View.LocationInfo')}

+
+ ); +}; + +export default memo(withGlobal((global, { hash }) => { + const session = hash ? global.activeWebSessions.byHash[hash] : undefined; + const bot = session ? global.users.byId[session.botId] : undefined; + return { + session, + bot, + }; +})(SettingsActiveWebsite)); diff --git a/src/components/left/settings/SettingsActiveWebsites.module.scss b/src/components/left/settings/SettingsActiveWebsites.module.scss new file mode 100644 index 000000000..3f41f1ebf --- /dev/null +++ b/src/components/left/settings/SettingsActiveWebsites.module.scss @@ -0,0 +1,21 @@ +.avatar { + width: 2rem; + height: 2rem; + margin-inline-end: 1.5rem; + + border-radius: 0.5rem; + :global(.Avatar__img) { + border-radius: 0.5rem; + } +} + +.clear-help { + margin-top: 0.5rem !important; + margin-bottom: 0 !important; +} + +:global(.subtitle) { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} diff --git a/src/components/left/settings/SettingsActiveWebsites.tsx b/src/components/left/settings/SettingsActiveWebsites.tsx new file mode 100644 index 000000000..6356a9d1a --- /dev/null +++ b/src/components/left/settings/SettingsActiveWebsites.tsx @@ -0,0 +1,166 @@ +import React, { + memo, useCallback, useEffect, useState, +} from '../../../lib/teact/teact'; +import { getActions, getGlobal, withGlobal } from '../../../global'; + +import type { FC } from '../../../lib/teact/teact'; +import type { ApiWebSession } from '../../../api/types'; + +import { formatPastTimeShort } from '../../../util/dateFormat'; +import { getUserFullName } from '../../../global/helpers'; +import buildClassName from '../../../util/buildClassName'; + +import useFlag from '../../../hooks/useFlag'; +import useLang from '../../../hooks/useLang'; +import useHistoryBack from '../../../hooks/useHistoryBack'; + +import ListItem from '../../ui/ListItem'; +import ConfirmDialog from '../../ui/ConfirmDialog'; +import SettingsActiveWebsite from './SettingsActiveWebsite'; +import Avatar from '../../common/Avatar'; + +import styles from './SettingsActiveWebsites.module.scss'; + +type OwnProps = { + isActive?: boolean; + onReset: () => void; +}; + +type StateProps = { + byHash: Record; + orderedHashes: string[]; +}; + +const SettingsActiveWebsites: FC = ({ + isActive, + byHash, + orderedHashes, + onReset, +}) => { + const { + terminateWebAuthorization, + terminateAllWebAuthorizations, + } = getActions(); + + const lang = useLang(); + const [isConfirmTerminateAllDialogOpen, openConfirmTerminateAllDialog, closeConfirmTerminateAllDialog] = useFlag(); + const [openedWebsiteHash, setOpenedWebsiteHash] = useState(); + const [isModalOpen, openModal, closeModal] = useFlag(); + + const handleTerminateAuthClick = useCallback((hash: string) => { + terminateWebAuthorization({ hash }); + }, [terminateWebAuthorization]); + + const handleTerminateAllAuth = useCallback(() => { + closeConfirmTerminateAllDialog(); + terminateAllWebAuthorizations(); + }, [closeConfirmTerminateAllDialog, terminateAllWebAuthorizations]); + + const handleOpenSessionModal = useCallback((hash: string) => { + setOpenedWebsiteHash(hash); + openModal(); + }, [openModal]); + + const handleCloseWebsiteModal = useCallback(() => { + setOpenedWebsiteHash(undefined); + closeModal(); + }, [closeModal]); + + // Close when empty + useEffect(() => { + if (!orderedHashes.length) { + onReset(); + } + }, [onReset, orderedHashes]); + + useHistoryBack({ + isActive, + onBack: onReset, + }); + + function renderSessions(sessionHashes: string[]) { + return ( +
+

+ {lang('WebSessionsTitle')} +

+ + {sessionHashes.map(renderSession)} +
+ ); + } + + function renderSession(sessionHash: string) { + const session = byHash[sessionHash]; + const bot = getGlobal().users.byId[session.botId]; + + return ( + { + handleTerminateAuthClick(session.hash); + }, + }]} + // eslint-disable-next-line react/jsx-no-bind + onClick={() => handleOpenSessionModal(session.hash)} + > + +
+ {formatPastTimeShort(lang, session.dateActive * 1000)} + {getUserFullName(bot)} + + {session.domain}, {session.browser}, {session.platform} + + {session.ip} {session.region} +
+
+ ); + } + + if (!orderedHashes.length) return undefined; + + return ( +
+
+ + {lang('AuthSessions.LogOutApplications')} + +

+ {lang('ClearOtherWebSessionsHelp')} +

+
+ {renderSessions(orderedHashes)} + + +
+ ); +}; + +export default memo(withGlobal( + (global): StateProps => { + const { byHash, orderedHashes } = global.activeWebSessions; + return { + byHash, + orderedHashes, + }; + }, +)(SettingsActiveWebsites)); diff --git a/src/components/left/settings/SettingsHeader.tsx b/src/components/left/settings/SettingsHeader.tsx index a2415bd77..8871464e1 100644 --- a/src/components/left/settings/SettingsHeader.tsx +++ b/src/components/left/settings/SettingsHeader.tsx @@ -129,6 +129,8 @@ const SettingsHeader: FC = ({ case SettingsScreens.ActiveSessions: return

{lang('SessionsTitle')}

; + case SettingsScreens.ActiveWebsites: + return

{lang('OtherWebSessions')}

; case SettingsScreens.PrivacyBlockedUsers: return

{lang('BlockedUsers')}

; diff --git a/src/components/left/settings/SettingsMain.tsx b/src/components/left/settings/SettingsMain.tsx index 52dc21cbd..07c080a20 100644 --- a/src/components/left/settings/SettingsMain.tsx +++ b/src/components/left/settings/SettingsMain.tsx @@ -33,7 +33,7 @@ const SettingsMain: FC = ({ sessionCount, lastSyncTime, }) => { - const { loadProfilePhotos, loadAuthorizations } = getActions(); + const { loadProfilePhotos, loadAuthorizations, loadWebAuthorizations } = getActions(); const lang = useLang(); const profileId = currentUser?.id; @@ -52,8 +52,9 @@ const SettingsMain: FC = ({ useEffect(() => { if (lastSyncTime) { loadAuthorizations(); + loadWebAuthorizations(); } - }, [lastSyncTime, loadAuthorizations]); + }, [lastSyncTime, loadAuthorizations, loadWebAuthorizations]); return (
diff --git a/src/components/left/settings/SettingsPrivacy.tsx b/src/components/left/settings/SettingsPrivacy.tsx index 2aaf0912e..136905c62 100644 --- a/src/components/left/settings/SettingsPrivacy.tsx +++ b/src/components/left/settings/SettingsPrivacy.tsx @@ -21,6 +21,7 @@ type StateProps = { hasPassword?: boolean; hasPasscode?: boolean; blockedCount: number; + webAuthCount: number; isSensitiveEnabled?: boolean; canChangeSensitive?: boolean; privacyPhoneNumber?: ApiPrivacySettings; @@ -34,11 +35,10 @@ type StateProps = { const SettingsPrivacy: FC = ({ isActive, - onScreenSelect, - onReset, hasPassword, hasPasscode, blockedCount, + webAuthCount, isSensitiveEnabled, canChangeSensitive, privacyPhoneNumber, @@ -48,7 +48,8 @@ const SettingsPrivacy: FC = ({ privacyGroupChats, privacyPhoneCall, privacyPhoneP2P, - + onScreenSelect, + onReset, }) => { const { loadPrivacySettings, @@ -114,6 +115,16 @@ const SettingsPrivacy: FC = ({ )}
+ {webAuthCount > 0 && ( + onScreenSelect(SettingsScreens.ActiveWebsites)} + > + {lang('PrivacySettings.WebSessions')} + {webAuthCount} + + )} ( hasPassword, hasPasscode: Boolean(hasPasscode), blockedCount: blocked.totalCount, + webAuthCount: global.activeWebSessions.orderedHashes.length, isSensitiveEnabled, canChangeSensitive, privacyPhoneNumber: privacy.phoneNumber, diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index dc7d0b7e4..500e4c0bc 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -6,7 +6,7 @@ import { getActions, withGlobal } from '../../global'; import type { LangCode } from '../../types'; import type { - ApiChat, ApiMessage, ApiUpdateAuthorizationStateType, ApiUpdateConnectionStateType, + ApiChat, ApiMessage, ApiUpdateAuthorizationStateType, ApiUpdateConnectionStateType, ApiUser, } from '../../api/types'; import type { GlobalState } from '../../global/types'; @@ -21,6 +21,7 @@ import { selectIsMediaViewerOpen, selectIsRightColumnShown, selectIsServiceChatReady, + selectUser, } from '../../global/selectors'; import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck'; import buildClassName from '../../util/buildClassName'; @@ -61,6 +62,7 @@ import RatePhoneCallModal from '../calls/phone/RatePhoneCallModal.async'; import WebAppModal from './WebAppModal.async'; import BotTrustModal from './BotTrustModal.async'; import BotAttachModal from './BotAttachModal.async'; +import UrlAuthModal from './UrlAuthModal.async'; import './Main.scss'; @@ -95,6 +97,8 @@ type StateProps = { webApp?: GlobalState['webApp']; botTrustRequest?: GlobalState['botTrustRequest']; botAttachRequest?: GlobalState['botAttachRequest']; + currentUser?: ApiUser; + urlAuth?: GlobalState['urlAuth']; }; const NOTIFICATION_INTERVAL = 1000; @@ -134,6 +138,8 @@ const Main: FC = ({ botTrustRequest, botAttachRequest, webApp, + currentUser, + urlAuth, }) => { const { sync, @@ -369,6 +375,7 @@ const Main: FC = ({ {audioMessage && } + = ({ url }) => { const lang = useLang(); const handleOpen = useCallback(() => { - window.open(ensureProtocol(url)); + window.open(ensureProtocol(url), '_blank', 'noopener'); toggleSafeLinkModal({ url: undefined }); }, [toggleSafeLinkModal, url]); diff --git a/src/components/main/UrlAuthModal.async.tsx b/src/components/main/UrlAuthModal.async.tsx new file mode 100644 index 000000000..a3b131eb7 --- /dev/null +++ b/src/components/main/UrlAuthModal.async.tsx @@ -0,0 +1,17 @@ +import React, { memo } from '../../lib/teact/teact'; +import { Bundles } from '../../util/moduleLoader'; + +import type { FC } from '../../lib/teact/teact'; +import type { OwnProps } from './UrlAuthModal'; + +import useModuleLoader from '../../hooks/useModuleLoader'; + +const UrlAuthModalAsync: FC = (props) => { + const { urlAuth } = props; + const UrlAuthModal = useModuleLoader(Bundles.Extra, 'UrlAuthModal', !urlAuth); + + // eslint-disable-next-line react/jsx-props-no-spreading + return UrlAuthModal ? : undefined; +}; + +export default memo(UrlAuthModalAsync); diff --git a/src/components/main/UrlAuthModal.module.scss b/src/components/main/UrlAuthModal.module.scss new file mode 100644 index 000000000..85b5d4172 --- /dev/null +++ b/src/components/main/UrlAuthModal.module.scss @@ -0,0 +1,3 @@ +.checkbox { + margin: 1rem 0; +} diff --git a/src/components/main/UrlAuthModal.tsx b/src/components/main/UrlAuthModal.tsx new file mode 100644 index 000000000..74a0bda6a --- /dev/null +++ b/src/components/main/UrlAuthModal.tsx @@ -0,0 +1,114 @@ +import React, { + memo, useCallback, useEffect, useState, +} from '../../lib/teact/teact'; +import { getActions, getGlobal } from '../../global'; + +import type { FC } from '../../lib/teact/teact'; +import type { ApiUser } from '../../api/types'; +import type { GlobalState } from '../../global/types'; + +import { ensureProtocol } from '../../util/ensureProtocol'; +import renderText from '../common/helpers/renderText'; +import { getUserFullName } from '../../global/helpers'; + +import useLang from '../../hooks/useLang'; +import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; + +import ConfirmDialog from '../ui/ConfirmDialog'; +import Checkbox from '../ui/Checkbox'; + +import styles from './UrlAuthModal.module.scss'; + +export type OwnProps = { + urlAuth?: GlobalState['urlAuth']; + currentUser?: ApiUser; +}; + +const UrlAuthModal: FC = ({ + urlAuth, currentUser, +}) => { + const { closeUrlAuthModal, acceptBotUrlAuth, acceptLinkUrlAuth } = getActions(); + const [isLoginChecked, setLoginChecked] = useState(true); + const [isWriteAccessChecked, setWriteAccessChecked] = useState(true); + const currentAuth = useCurrentOrPrev(urlAuth, false); + const { domain, botId, shouldRequestWriteAccess } = currentAuth?.request || {}; + const bot = botId ? getGlobal().users.byId[botId] : undefined; + + const lang = useLang(); + + const handleOpen = useCallback(() => { + if (urlAuth?.url && isLoginChecked) { + const acceptAction = urlAuth.button ? acceptBotUrlAuth : acceptLinkUrlAuth; + acceptAction({ + isWriteAllowed: isWriteAccessChecked, + }); + } else { + window.open(ensureProtocol(currentAuth?.url), '_blank', 'noopener'); + } + closeUrlAuthModal(); + }, [ + urlAuth, isLoginChecked, closeUrlAuthModal, acceptBotUrlAuth, acceptLinkUrlAuth, isWriteAccessChecked, currentAuth, + ]); + + const handleDismiss = useCallback(() => { + closeUrlAuthModal(); + }, [closeUrlAuthModal]); + + const handleLoginChecked = useCallback((value: boolean) => { + setLoginChecked(value); + setWriteAccessChecked(value); + }, [setLoginChecked]); + + // Reset on re-open + useEffect(() => { + if (domain) { + setLoginChecked(true); + setWriteAccessChecked(Boolean(shouldRequestWriteAccess)); + } + }, [shouldRequestWriteAccess, domain]); + + return ( + + {renderText(lang('OpenUrlAlert2', currentAuth?.url), ['links'])} + {domain && ( + + {renderText( + lang('Conversation.OpenBotLinkLogin', [domain, getUserFullName(currentUser)]), + ['simple_markdown'], + )} + + )} + onCheck={handleLoginChecked} + className={styles.checkbox} + /> + )} + {shouldRequestWriteAccess && ( + + {renderText( + lang('Conversation.OpenBotLinkAllowMessages', getUserFullName(bot)), + ['simple_markdown'], + )} + + )} + onCheck={setWriteAccessChecked} + disabled={!isLoginChecked} + className={styles.checkbox} + /> + )} + + ); +}; + +export default memo(UrlAuthModal); diff --git a/src/components/main/WebAppModal.tsx b/src/components/main/WebAppModal.tsx index 270c2abab..7157be965 100644 --- a/src/components/main/WebAppModal.tsx +++ b/src/components/main/WebAppModal.tsx @@ -166,6 +166,10 @@ const WebAppModal: FC = ({ }); }, [bot, isInstalled, toggleBotInAttachMenu]); + const handleCloseClick = useCallback(() => { + closeWebApp(); + }, [closeWebApp]); + const openBotChat = useCallback(() => { openChat({ id: bot!.id, @@ -197,7 +201,7 @@ const WebAppModal: FC = ({ color="translucent" size="smaller" ariaLabel={lang('Close')} - onClick={closeWebApp} + onClick={handleCloseClick} > @@ -219,7 +223,9 @@ const WebAppModal: FC = ({
); - }, [lang, closeWebApp, bot, MoreMenuButton, handleRefreshClick, isInstalled, handleToggleClick, chat, openBotChat]); + }, [ + lang, handleCloseClick, bot, MoreMenuButton, chat, openBotChat, handleRefreshClick, isInstalled, handleToggleClick, + ]); const prevMainButtonColor = usePrevious(mainButton?.color, true); const prevMainButtonTextColor = usePrevious(mainButton?.textColor, true); diff --git a/src/components/middle/message/InlineButtons.scss b/src/components/middle/message/InlineButtons.scss index de45de40c..f47b061df 100644 --- a/src/components/middle/message/InlineButtons.scss +++ b/src/components/middle/message/InlineButtons.scss @@ -4,8 +4,9 @@ max-width: var(--max-width); .row { - display: flex; - flex-direction: row; + display: grid; + grid-auto-columns: minmax(0, 1fr); + grid-auto-flow: column; } .Button { diff --git a/src/components/middle/message/InlineButtons.tsx b/src/components/middle/message/InlineButtons.tsx index c3f3247d1..342e18df6 100644 --- a/src/components/middle/message/InlineButtons.tsx +++ b/src/components/middle/message/InlineButtons.tsx @@ -1,6 +1,6 @@ -import type { FC } from '../../../lib/teact/teact'; import React from '../../../lib/teact/teact'; +import type { FC } from '../../../lib/teact/teact'; import type { ApiKeyboardButton, ApiMessage } from '../../../api/types'; import { RE_TME_LINK } from '../../../config'; @@ -18,6 +18,30 @@ type OwnProps = { const InlineButtons: FC = ({ message, onClick }) => { const lang = useLang(); + + const renderIcon = (button: ApiKeyboardButton) => { + const { type } = button; + switch (type) { + case 'url': { + if (!RE_TME_LINK.test(button.url)) { + return ; + } + break; + } + case 'urlAuth': + return ; + case 'buy': + case 'receipt': + return ; + case 'switchBotInline': + return ; + case 'webView': + case 'simpleWebView': + return ; + } + return undefined; + }; + return (
{message.inlineButtons!.map((row) => ( @@ -31,10 +55,7 @@ const InlineButtons: FC = ({ message, onClick }) => { onClick={() => onClick({ messageId: message.id, button })} > {renderText(lang(button.text))} - {['buy', 'receipt'].includes(button.type) && } - {button.type === 'url' && !RE_TME_LINK.test(button.url) && } - {button.type === 'switchBotInline' && } - {['webView', 'simpleWebView'].includes(button.type) && } + {renderIcon(button)} ))}
diff --git a/src/components/middle/message/Location.tsx b/src/components/middle/message/Location.tsx index fb5e91d30..9281da4ab 100644 --- a/src/components/middle/message/Location.tsx +++ b/src/components/middle/message/Location.tsx @@ -108,7 +108,7 @@ const Location: FC = ({ const handleClick = () => { const url = prepareMapUrl(point.lat, point.long, zoom); - window.open(url, '_blank')?.focus(); + window.open(url, '_blank', 'noopener')?.focus(); }; const updateCountdown = useCallback((countdownEl: HTMLDivElement) => { diff --git a/src/components/ui/Checkbox.tsx b/src/components/ui/Checkbox.tsx index b644c67b3..ddf04a333 100644 --- a/src/components/ui/Checkbox.tsx +++ b/src/components/ui/Checkbox.tsx @@ -23,6 +23,7 @@ type OwnProps = { blocking?: boolean; isLoading?: boolean; withCheckedCallback?: boolean; + className?: string; onChange?: (e: ChangeEvent) => void; onCheck?: (isChecked: boolean) => void; }; @@ -39,6 +40,7 @@ const Checkbox: FC = ({ round, blocking, isLoading, + className, onChange, onCheck, }) => { @@ -53,16 +55,17 @@ const Checkbox: FC = ({ } }, [onChange, onCheck]); - const className = buildClassName( + const labelClassName = buildClassName( 'Checkbox', disabled && 'disabled', round && 'round', isLoading && 'loading', blocking && 'blocking', + className, ); return ( -