Introduce Electron version (#2053)

This commit is contained in:
Alexander Zinchuk 2023-06-02 15:06:26 +02:00
parent 28fc59e070
commit 82f42b6e35
56 changed files with 9631 additions and 4155 deletions

View File

@ -37,5 +37,76 @@ Example usage:
await invoke(new GramJs.help.GetAppConfig())
```
## Electron
Electron allows building a native application that can be installed on Windows, macOS, and Linux.
#### NPM scripts
- `npm run electron:dev`
Run Electron in development mode, concurrently starts 3 processes with watch for changes: main (main Electron process), renderer (FE code) and Webpack for Electron (compiles main Electron process from TypeScript).
- `npm run electron:webpack`
The main process code for Electron, which includes preload functionality, is written in TypeScript and is compiled using the `webpack-electron.config.js` configuration to generate JavaScript code.
- `npm run electron:build`
Prepare renderer (FE code) build, compile Electron main process code, install and build native dependencies, is used before packaging or publishing.
- `npm run electron:package:staging`
Create packages for macOS, Windows and Linux in `dist-electron` folders with `APP_ENV` as `staging` (allows to open DevTools, includes sourcemaps and does not minify built JavaScript code), can be used for manual distribution and testing packaged application.
- `npm run electron:package:production`
Create packages for macOS, Windows and Linux in `dist-electron` folders with `APP_ENV` as `production` (disabled DevTools, minified built JavaScript code), can be used for manual distribution and testing packaged application.
- `npm run electron:publish`
Create packages for macOS, Windows and Linux in `dist-electron` folder and publish release to GitHub, which allows supporting autoupdates. See [GitHub release workflow](#github-release) for more info.
#### Code signing on MacOS
To sign the code of your application, follow these steps:
- Install certificates from `/certs` folder to `login` folder of your Keychain.
- Download and install `Developer ID - G2` certificate from the [Apple PKI](https://www.apple.com/certificateauthority/) page.
- Under the Keychain application, go to the private key associated with your developer certificate. Then do `key > Get Info > Access Control`. Down there, make sure your application (Xcode) is in the list `Always allow access by these applications` and make sure `Confirm before allowing access` is turned on.
- A valid and appropriate identity from your keychain will be automatically used when you publish your application.
More info in the [official documentation](https://www.electronjs.org/docs/latest/tutorial/code-signing).
#### Notarize on MacOS
Application notarization is done with [electron-builder-notarize](https://github.com/karaggeorge/electron-builder-notarize) module, which requires `APPLE_ID` and `APPLE_ID_PASSWORD` environment variables to be passed.
How to obtain app-specific password:
- Sign in to [appleid.apple.com](appleid.apple.com).
- In the "Sign-In and Security" section, select "App-Specific Passwords".
- Select "Generate an app-specific password" or select the Add button, then follow the steps on your screen.
#### GitHub release
##### GitHub access token
In order to publish new release, you need to add GitHub access token to `.env`. Generate a GitHub access token by going to https://github.com/settings/tokens/new. The access token should have the repo scope/permission. Once you have the token, assign it to an environment variable:
```
# .env
GH_TOKEN="{YOUR_TOKEN_HERE}"
```
##### Publish settings
Publish configuration in `src/electron/config.yml` config file allows to set GitHub repository owner/name.
##### Release workflow
- Run `npm run electron:publish`, which will create new draft release and upload build artefacts to newly reated release. Version of created release will be the same as in `package.json`.
- Once you are done, publish the release. GitHub will tag the latest commit.
## Bug reports and Suggestions
If you find an issue with this app, let Telegram know using the [Suggestions Platform](https://bugs.telegram.org/c/4002).

View File

@ -1,19 +0,0 @@
/* eslint-env node */
// Comes from: https://raw.githubusercontent.com/statoscope/statoscope.tech/main/custom-ext.js
module.exports = class WebpackContextExtension {
constructor() {
this.context = '';
}
handleCompiler(compiler) {
this.context = compiler.context;
}
getExtension() {
return {
descriptor: { name: 'custom-webpack-extension-context', version: '1.0.0' },
payload: { context: this.context },
};
}
};

12858
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -25,6 +25,12 @@
"test:record": "playwright codegen localhost:1235",
"prepare": "husky install",
"statoscope:validate-diff": "statoscope validate --input input.json --reference reference.json",
"electron:dev": "npm run electron:webpack && IS_ELECTRON=true concurrently -n main,renderer,electron \"npm run electron:webpack -- --watch\" \"npm run dev\" \"electronmon dist/electron\"",
"electron:webpack": "cross-env APP_ENV=$ENV webpack --config ./webpack-electron.config.ts",
"electron:build": "cross-env IS_ELECTRON=true npm run build:$ENV && electron-builder install-app-deps && electron-rebuild && ENV=$ENV npm run electron:webpack",
"electron:package:staging": "ENV=staging npm run electron:build && npx rimraf dist-electron && electron-builder build --win --mac --linux --config src/electron/config.yml",
"electron:package:production": "ENV=production npm run electron:build && npx rimraf dist-electron && electron-builder build --win --mac --linux --config src/electron/config.yml",
"electron:publish": "npm run electron:package:production -- -p always",
"postinstall": "(cd dev/eslint-multitab && npm i)"
},
"engines": {
@ -40,6 +46,9 @@
"*.{ts,tsx,js}": "eslint --fix",
"*.{css,scss}": "stylelint --fix"
},
"electronmon": {
"logLevel": "quiet"
},
"author": "Alexander Zinchuk (alexander@zinchuk.com)",
"license": "GPL-3.0-or-later",
"devDependencies": {
@ -51,6 +60,8 @@
"@babel/preset-env": "^7.20.2",
"@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.21.0",
"@babel/register": "^7.21.0",
"@electron/rebuild": "^3.2.10",
"@glen/jest-raw-loader": "^2.0.0",
"@playwright/test": "^1.31.2",
"@statoscope/cli": "^5.26.1",
@ -61,6 +72,8 @@
"@types/jest": "^29.5.0",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@types/webpack": "^5.28.1",
"@types/webpack-dev-server": "^4.7.2",
"@typescript-eslint/eslint-plugin": "^5.55.0",
"@typescript-eslint/parser": "^5.55.0",
"@webpack-cli/serve": "^2.0.1",
@ -69,9 +82,15 @@
"babel-plugin-transform-import-meta": "^2.2.0",
"browserlist": "^1.0.1",
"buffer": "^6.0.3",
"concurrently": "^7.6.0",
"cross-env": "^7.0.3",
"css-loader": "^6.7.3",
"dotenv": "^16.0.3",
"electron": "^22.0.0",
"electron-builder": "^23.6.0",
"electron-updater": "^5.3.0",
"electron-window-state": "^5.0.3",
"electronmon": "^2.0.2",
"eslint": "^8.36.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^17.0.0",
@ -129,7 +148,8 @@
"os-browserify": "^0.3.0",
"pako": "^2.1.0",
"path-browserify": "^1.0.1",
"qr-code-styling": "github:zubiden/qr-code-styling#dbbfed0"
"qr-code-styling": "github:zubiden/qr-code-styling#dbbfed0",
"v8-compile-cache": "^2.3.0"
},
"optionalDependencies": {
"fsevents": "2.3.2"

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View File

@ -59,6 +59,14 @@
margin-bottom: 3rem;
}
}
body.is-electron #auth-phone-number-form & {
padding-top: 3rem;
.form {
min-height: 0;
}
}
}
#auth-registration-form,
@ -68,10 +76,18 @@
#auth-qr-form {
height: 100%;
overflow-y: auto;
body.is-electron.is-macos & {
-webkit-app-region: drag;
.input-group {
-webkit-app-region: no-drag;
}
}
}
#auth-phone-number-form {
form {
.form {
min-height: 26.25rem;
}

View File

@ -1,5 +1,5 @@
import type { FC } from '../../lib/teact/teact';
import React, { memo } from '../../lib/teact/teact';
import React, { memo, useRef } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import type { GlobalState } from '../../global/types';
@ -8,6 +8,7 @@ import '../../global/actions/initial';
import { PLATFORM_ENV } from '../../util/windowEnvironment';
import useHistoryBack from '../../hooks/useHistoryBack';
import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
import useElectronDrag from '../../hooks/useElectronDrag';
import Transition from '../ui/Transition';
import AuthPhoneNumber from './AuthPhoneNumber';
@ -43,6 +44,10 @@ const Auth: FC<StateProps> = ({
onBack: handleChangeAuthorizationMethod,
});
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
useElectronDrag(containerRef);
// For animation purposes
const renderingAuthState = useCurrentOrPrev(
authState !== 'authorizationStateReady' ? authState : undefined,
@ -84,7 +89,7 @@ const Auth: FC<StateProps> = ({
}
return (
<Transition activeKey={getActiveKey()} name="fade" className="Auth">
<Transition activeKey={getActiveKey()} name="fade" className="Auth" ref={containerRef}>
{getScreen()}
</Transition>
);

View File

@ -216,7 +216,7 @@ const AuthPhoneNumber: FC<StateProps> = ({
<div id="logo" />
<h1>Telegram</h1>
<p className="note">{lang('StartText')}</p>
<form action="" onSubmit={handleSubmit}>
<form className="form" action="" onSubmit={handleSubmit}>
<CountryCodeInput
id="sign-in-phone-code"
value={country}

View File

@ -4,7 +4,9 @@ import type { TeactNode } from '../../../lib/teact/teact';
import type { TextPart } from '../../../types';
import EMOJI_REGEX from '../../../lib/twemojiRegex';
import { RE_LINK_TEMPLATE, RE_MENTION_TEMPLATE } from '../../../config';
import {
RE_LINK_TEMPLATE, RE_MENTION_TEMPLATE, IS_ELECTRON, PRODUCTION_URL,
} from '../../../config';
import { IS_EMOJI_SUPPORTED } from '../../../util/windowEnvironment';
import {
fixNonStandardEmoji,
@ -108,7 +110,7 @@ function replaceEmojis(textParts: TextPart[], size: 'big' | 'small', type: 'jsx'
return emojis.reduce((emojiResult: TextPart[], emoji, i) => {
const code = nativeToUnifiedExtendedWithCache(emoji);
if (!code) return emojiResult;
const src = `./img-apple-${size === 'big' ? '160' : '64'}/${code}.png`;
const src = `${IS_ELECTRON ? PRODUCTION_URL : '.'}/img-apple-${size === 'big' ? '160' : '64'}/${code}.png`;
const className = buildClassName(
'emoji',
size === 'small' && 'emoji-small',
@ -132,7 +134,7 @@ function replaceEmojis(textParts: TextPart[], size: 'big' | 'small', type: 'jsx'
`<img\
draggable="false"\
class="${className}"\
src="./img-apple-${size === 'big' ? '160' : '64'}/${code}.png"\
src={src}\
alt="${emoji}"\
/>`,
);

View File

@ -40,4 +40,27 @@
margin-left: 0.25rem;
}
}
body.is-electron.is-macos & {
-webkit-app-region: drag;
.SearchInput {
-webkit-app-region: no-drag;
}
}
body.is-electron.is-macos #Main:not(.is-fullscreen) &:not(#TopicListHeader) {
justify-content: space-between;
padding: 0.5rem 0.5rem 0.5rem 4.5rem;
.SearchInput {
margin-left: 0.5rem;
max-width: calc(100% - 2.75rem);
}
.Menu.main-menu .bubble {
--offset-y: 100%;
--offset-x: -4.125rem;
}
}
}

View File

@ -9,7 +9,7 @@ import { LeftColumnContent, SettingsScreens } from '../../types';
import type { ReducerAction } from '../../hooks/useReducer';
import type { FoldersActions } from '../../hooks/reducers/useFoldersReducer';
import { IS_MAC_OS, IS_PWA, LAYERS_ANIMATION_NAME } from '../../util/windowEnvironment';
import { IS_MAC_OS, IS_APP, LAYERS_ANIMATION_NAME } from '../../util/windowEnvironment';
import captureEscKeyListener from '../../util/captureEscKeyListener';
import { selectCurrentChat, selectIsForumPanelOpen, selectTabState } from '../../global/selectors';
import useFoldersReducer from '../../hooks/reducers/useFoldersReducer';
@ -381,11 +381,11 @@ function LeftColumn({
useHotkeys({
'Mod+Shift+F': handleHotkeySearch,
'Mod+Shift+S': handleHotkeySavedMessages,
...(IS_PWA && {
...(IS_APP && {
'Mod+0': handleHotkeySavedMessages,
'Mod+9': handleArchivedChats,
}),
...(IS_MAC_OS && IS_PWA && { 'Mod+,': handleHotkeySettings }),
...(IS_MAC_OS && IS_APP && { 'Mod+,': handleHotkeySettings }),
});
useEffect(() => {

View File

@ -16,7 +16,7 @@ import {
CHAT_HEIGHT_PX,
CHAT_LIST_SLICE,
} from '../../../config';
import { IS_MAC_OS, IS_PWA } from '../../../util/windowEnvironment';
import { IS_MAC_OS, IS_APP } from '../../../util/windowEnvironment';
import { getPinnedChatsCount, getOrderKey } from '../../../util/folderManager';
import buildClassName from '../../../util/buildClassName';
@ -96,7 +96,7 @@ const ChatList: FC<OwnProps> = ({
// Support <Cmd>+<Digit> to navigate between chats
useEffect(() => {
if (!isActive || !orderedIds || !IS_PWA) {
if (!isActive || !orderedIds || !IS_APP) {
return undefined;
}

View File

@ -217,7 +217,7 @@ const ForumPanel: FC<OwnProps & StateProps> = ({
)}
onTransitionEnd={!isOpen ? onCloseAnimationEnd : undefined}
>
<div className="left-header">
<div id="TopicListHeader" className="left-header">
<Button
round
size="smaller"

View File

@ -8,6 +8,7 @@ import type { SettingsScreens } from '../../../types';
import { LeftColumnContent } from '../../../types';
import type { FolderEditDispatch } from '../../../hooks/reducers/useFoldersReducer';
import { IS_ELECTRON } from '../../../config';
import { IS_TOUCH_ENV } from '../../../util/windowEnvironment';
import buildClassName from '../../../util/buildClassName';
import useShowTransition from '../../../hooks/useShowTransition';
@ -117,7 +118,11 @@ const LeftMain: FC<OwnProps> = ({
}, [closeForumPanel, onContentChange]);
const handleUpdateClick = useCallback(() => {
window.location.reload();
if (IS_ELECTRON) {
window.electron?.installUpdate();
} else {
window.location.reload();
}
}, []);
const handleSelectNewChannel = useCallback(() => {

View File

@ -102,6 +102,10 @@
.extra-spacing {
position: relative;
margin-left: 0.8125rem;
body.is-electron.is-macos #Main:not(.is-fullscreen) & {
margin-left: 0.5rem;
}
}
.emoji-status-effect {

View File

@ -1,6 +1,6 @@
import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useCallback, useEffect, useMemo,
memo, useCallback, useEffect, useMemo, useRef,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
@ -18,14 +18,15 @@ import {
FEEDBACK_URL,
IS_BETA,
IS_TEST,
IS_ELECTRON,
PRODUCTION_HOSTNAME,
} from '../../../config';
import { IS_APP } from '../../../util/windowEnvironment';
import {
INITIAL_PERFORMANCE_STATE_MAX,
INITIAL_PERFORMANCE_STATE_MID,
INITIAL_PERFORMANCE_STATE_MIN,
} from '../../../global/initialState';
import { IS_PWA } from '../../../util/windowEnvironment';
import buildClassName from '../../../util/buildClassName';
import { formatDateToString } from '../../../util/dateFormat';
import { setPermanentWebVersion } from '../../../util/permanentWebVersion';
@ -40,6 +41,8 @@ import { useHotkeys } from '../../../hooks/useHotkeys';
import { getPromptInstall } from '../../../util/installPrompt';
import captureEscKeyListener from '../../../util/captureEscKeyListener';
import useLeftHeaderButtonRtlForumTransition from './hooks/useLeftHeaderButtonRtlForumTransition';
import { useFullscreenStatus } from '../../../hooks/useFullscreen';
import useElectronDrag from '../../../hooks/useElectronDrag';
import { useFolderManagerForUnreadCounters } from '../../../hooks/useFolderManager';
import useAppLayout from '../../../hooks/useAppLayout';
@ -163,7 +166,7 @@ const LeftMainHeader: FC<OwnProps & StateProps> = ({
'Ctrl+Shift+L': handleLockScreenHotkey,
'Alt+Shift+L': handleLockScreenHotkey,
'Meta+Shift+L': handleLockScreenHotkey,
...(IS_PWA && { 'Mod+L': handleLockScreenHotkey }),
...(IS_APP && { 'Mod+L': handleLockScreenHotkey }),
} : undefined);
const withOtherVersions = window.location.hostname === PRODUCTION_HOSTNAME || IS_TEST;
@ -266,12 +269,18 @@ const LeftMainHeader: FC<OwnProps & StateProps> = ({
? (animationLevel === ANIMATION_LEVEL_MAX ? 'max' : 'mid')
: 'min';
const isFullscreen = useFullscreenStatus();
// Disable dropdown menu RTL animation for resize
const {
shouldDisableDropdownMenuTransitionRef,
handleDropdownMenuTransitionEnd,
} = useLeftHeaderButtonRtlForumTransition(shouldHideSearch);
// eslint-disable-next-line no-null/no-null
const headerRef = useRef<HTMLDivElement>(null);
useElectronDrag(headerRef);
const menuItems = useMemo(() => (
<>
<MenuItem
@ -369,17 +378,19 @@ const LeftMainHeader: FC<OwnProps & StateProps> = ({
return (
<div className="LeftMainHeader">
<div id="LeftMainHeader" className="left-header">
<div id="LeftMainHeader" className="left-header" ref={headerRef}>
{lang.isRtl && <div className="DropdownMenuFiller" />}
<DropdownMenu
trigger={MainButton}
footer={`${APP_NAME} ${versionString}`}
className={buildClassName(
'main-menu',
lang.isRtl && 'rtl',
shouldHideSearch && lang.isRtl && 'right-aligned',
shouldDisableDropdownMenuTransitionRef.current && lang.isRtl && 'disable-transition',
)}
positionX={shouldHideSearch && lang.isRtl ? 'right' : 'left'}
transformOriginX={IS_ELECTRON && !isFullscreen ? 90 : undefined}
onTransitionEnd={lang.isRtl ? handleDropdownMenuTransitionEnd : undefined}
>
{menuItems}

View File

@ -15,10 +15,11 @@ import type {
ApiUser,
} from '../../api/types';
import type { ApiLimitTypeWithModal, TabState } from '../../global/types';
import { ElectronEvent } from '../../types/electron';
import '../../global/actions/all';
import {
BASE_EMOJI_KEYWORD_LANG, DEBUG, INACTIVE_MARKER,
BASE_EMOJI_KEYWORD_LANG, DEBUG, INACTIVE_MARKER, IS_ELECTRON,
} from '../../config';
import { IS_ANDROID } from '../../util/windowEnvironment';
import {
@ -52,6 +53,7 @@ import useForceUpdate from '../../hooks/useForceUpdate';
import useShowTransition from '../../hooks/useShowTransition';
import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck';
import useInterval from '../../hooks/useInterval';
import { useFullscreenStatus } from '../../hooks/useFullscreen';
import useAppLayout from '../../hooks/useAppLayout';
import useTimeout from '../../hooks/useTimeout';
import useFlag from '../../hooks/useFlag';
@ -239,6 +241,7 @@ const Main: FC<OwnProps & StateProps> = ({
loadTopReactions,
loadRecentReactions,
loadFeaturedEmojiStickers,
setIsAppUpdateAvailable,
} = getActions();
if (DEBUG && !DEBUG_isLogged) {
@ -271,7 +274,27 @@ const Main: FC<OwnProps & StateProps> = ({
}
}, [isDesktop, isLeftColumnOpen, isMiddleColumnOpen, isMobile, toggleLeftColumn]);
useInterval(checkAppVersion, isMasterTab ? APP_OUTDATED_TIMEOUT_MS : undefined, true);
useInterval(checkAppVersion, (isMasterTab && !IS_ELECTRON) ? APP_OUTDATED_TIMEOUT_MS : undefined, true);
useEffect(() => {
if (!IS_ELECTRON) {
return undefined;
}
const removeUpdateDownloadedListener = window.electron?.on(ElectronEvent.UPDATE_DOWNLOADED, () => {
setIsAppUpdateAvailable(true);
});
const removeUpdateErrorListener = window.electron?.on(ElectronEvent.UPDATE_ERROR, () => {
setIsAppUpdateAvailable(false);
removeUpdateDownloadedListener?.();
});
return () => {
removeUpdateErrorListener?.();
removeUpdateDownloadedListener?.();
};
}, []);
// Initial API calls
useEffect(() => {
@ -427,6 +450,8 @@ const Main: FC<OwnProps & StateProps> = ({
const willAnimateRightColumnRef = useRef(false);
const [isNarrowMessageList, setIsNarrowMessageList] = useState(isRightColumnOpen);
const isFullscreen = useFullscreenStatus();
// Handle opening right column
useSyncEffect(([prevIsRightColumnOpen]) => {
if (prevIsRightColumnOpen === undefined || isRightColumnOpen === prevIsRightColumnOpen) {
@ -459,6 +484,7 @@ const Main: FC<OwnProps & StateProps> = ({
willAnimateRightColumnRef.current && 'right-column-animating',
isNarrowMessageList && 'narrow-message-list',
shouldSkipHistoryAnimations && 'history-animation-disabled',
isFullscreen && 'is-fullscreen',
);
const handleBlur = useCallback(() => {

View File

@ -97,6 +97,14 @@
padding: 0.5rem;
}
}
body.is-electron.is-macos & {
-webkit-app-region: drag;
}
body.is-electron.is-macos #Main:not(.is-fullscreen) & {
padding-left: 5rem;
}
}
& > .Transition,

View File

@ -41,6 +41,7 @@ import { exitPictureInPictureIfNeeded, usePictureInPictureSignal } from '../../h
import useLang from '../../hooks/useLang';
import usePrevious from '../../hooks/usePrevious';
import { useMediaProps } from './hooks/useMediaProps';
import useElectronDrag from '../../hooks/useElectronDrag';
import useAppLayout from '../../hooks/useAppLayout';
import { useStateRef } from '../../hooks/useStateRef';
@ -182,6 +183,10 @@ const MediaViewer: FC<StateProps> = ({
}
}, [isMobile, isOpen]);
// eslint-disable-next-line no-null/no-null
const headerRef = useRef<HTMLDivElement>(null);
useElectronDrag(headerRef);
const forceUpdate = useForceUpdate();
useEffect(() => {
const mql = window.matchMedia(MEDIA_VIEWER_MEDIA_QUERY);
@ -325,7 +330,7 @@ const MediaViewer: FC<StateProps> = ({
shouldAnimateFirstRender
noCloseTransition={shouldSkipHistoryAnimations}
>
<div className="media-viewer-head" dir={lang.isRtl ? 'rtl' : undefined}>
<div className="media-viewer-head" dir={lang.isRtl ? 'rtl' : undefined} ref={headerRef}>
{isMobile && (
<Button
className="media-viewer-close"

View File

@ -13,7 +13,7 @@ import { MAIN_THREAD_ID } from '../../api/types';
import type { IAnchorPosition } from '../../types';
import { ManagementScreens } from '../../types';
import { ARE_CALLS_SUPPORTED, IS_PWA } from '../../util/windowEnvironment';
import { ARE_CALLS_SUPPORTED, IS_APP } from '../../util/windowEnvironment';
import {
isChatBasicGroup, isChatChannel, isChatSuperGroup, isUserId,
} from '../../global/helpers';
@ -174,7 +174,7 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
}
const handleHotkeySearchClick = useCallback((e: KeyboardEvent) => {
if (!canSearch || !IS_PWA || e.shiftKey) {
if (!canSearch || !IS_APP || e.shiftKey) {
return;
}

View File

@ -374,6 +374,20 @@
@media (max-width: 600px) {
@include mobile-header-styles();
}
body.is-electron.is-macos & {
-webkit-app-region: drag;
}
body.is-electron.is-macos #Main:not(.left-column-open):not(.is-fullscreen) & {
@media (max-width: 925px) {
padding-left: 5rem;
.back-button {
margin-left: -0.5rem;
}
}
}
}
@keyframes fade-in {

View File

@ -52,6 +52,7 @@ import buildClassName from '../../util/buildClassName';
import useLang from '../../hooks/useLang';
import useConnectionStatus from '../../hooks/useConnectionStatus';
import usePrevious from '../../hooks/usePrevious';
import useElectronDrag from '../../hooks/useElectronDrag';
import useAppLayout from '../../hooks/useAppLayout';
import useDerivedState from '../../hooks/useDerivedState';
@ -409,6 +410,8 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
const isPinnedMessagesFullWidth = isAudioPlayerRendered
|| (!isMobile && hasButtonInHeader && windowWidth < MAX_SCREEN_WIDTH_FOR_EXPAND_PINNED_MESSAGES);
useElectronDrag(componentRef);
return (
<div className="MiddleHeader" ref={componentRef}>
<Transition

View File

@ -2,6 +2,7 @@ import React, { memo, useCallback } from '../../../lib/teact/teact';
import type { FC } from '../../../lib/teact/teact';
import { IS_ELECTRON, PRODUCTION_URL } from '../../../config';
import { IS_EMOJI_SUPPORTED } from '../../../util/windowEnvironment';
import { handleEmojiLoad, LOADED_EMOJIS } from '../../../util/emoji';
import buildClassName from '../../../util/buildClassName';
@ -29,7 +30,7 @@ const EmojiButton: FC<OwnProps> = ({
focus && 'focus',
);
const src = `./img-apple-64/${emoji.image}.png`;
const src = `${IS_ELECTRON ? PRODUCTION_URL : '.'}/img-apple-64/${emoji.image}.png`;
const isLoaded = LOADED_EMOJIS.has(src);
return (

View File

@ -60,6 +60,7 @@ export type OwnProps = {
message: ApiMessage;
album?: IAlbum;
anchor: IAnchorPosition;
targetHref?: string;
messageListType: MessageListType;
noReplies?: boolean;
detectedLanguage?: string;
@ -121,6 +122,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
customEmojiSets,
album,
anchor,
targetHref,
noOptions,
canSendNow,
hasFullInfo,
@ -480,6 +482,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
enabledReactions={enabledReactions}
maxUniqueReactions={maxUniqueReactions}
anchor={anchor}
targetHref={targetHref}
canShowReactionsCount={canShowReactionsCount}
canShowReactionList={canShowReactionList}
canSendNow={canSendNow}

View File

@ -32,7 +32,7 @@ import { AudioOrigin } from '../../../types';
import { MAIN_THREAD_ID } from '../../../api/types';
import { IS_ANDROID, IS_TOUCH_ENV } from '../../../util/windowEnvironment';
import { EMOJI_STATUS_LOOP_LIMIT, GENERAL_TOPIC_ID } from '../../../config';
import { EMOJI_STATUS_LOOP_LIMIT, GENERAL_TOPIC_ID, IS_ELECTRON } from '../../../config';
import {
selectChat,
selectChatMessage,
@ -394,11 +394,12 @@ const Message: FC<OwnProps & StateProps> = ({
const {
isContextMenuOpen,
contextMenuPosition,
contextMenuTarget,
handleBeforeContextMenu,
handleContextMenu: onContextMenu,
handleContextMenuClose,
handleContextMenuHide,
} = useContextMenuHandlers(ref, IS_TOUCH_ENV && isInSelectMode, true, IS_ANDROID);
} = useContextMenuHandlers(ref, IS_TOUCH_ENV && isInSelectMode, !IS_ELECTRON, IS_ANDROID);
useEffect(() => {
if (isContextMenuOpen) {
@ -1324,6 +1325,7 @@ const Message: FC<OwnProps & StateProps> = ({
<ContextMenuContainer
isOpen={isContextMenuOpen}
anchor={contextMenuPosition}
targetHref={contextMenuTarget?.matches('a[href]') ? (contextMenuTarget as HTMLAnchorElement).href : undefined}
message={message}
album={album}
chatUsername={chatUsername?.username}

View File

@ -42,6 +42,7 @@ type OwnProps = {
topReactions?: ApiReaction[];
isOpen: boolean;
anchor: IAnchorPosition;
targetHref?: string;
message: ApiMessage | ApiSponsoredMessage;
canSendNow?: boolean;
enabledReactions?: ApiChatReactions;
@ -129,6 +130,7 @@ const MessageContextMenu: FC<OwnProps> = ({
enabledReactions,
maxUniqueReactions,
anchor,
targetHref,
canSendNow,
canReschedule,
canBuyPremium,
@ -238,7 +240,7 @@ const MessageContextMenu: FC<OwnProps> = ({
const copyOptions = isSponsoredMessage
? []
: getMessageCopyOptions(
message, handleAfterCopy, canCopyLink ? onCopyLink : undefined, onCopyMessages, onCopyNumber,
message, targetHref, handleAfterCopy, canCopyLink ? onCopyLink : undefined, onCopyMessages, onCopyNumber,
);
const getTriggerElement = useCallback(() => {

View File

@ -16,6 +16,7 @@ import {
CLIPBOARD_ITEM_SUPPORTED,
copyHtmlToClipboard,
copyImageToClipboard,
copyTextToClipboard,
} from '../../../../util/clipboard';
import getMessageIdsForSelectedText from '../../../../util/getMessageIdsForSelectedText';
import { renderMessageText } from '../../../common/helpers/renderMessageText';
@ -28,6 +29,7 @@ type ICopyOptions = {
export function getMessageCopyOptions(
message: ApiMessage,
href?: string,
afterEffect?: () => void,
onCopyLink?: () => void,
onCopyMessages?: (messageIds: number[]) => void,
@ -55,7 +57,17 @@ export function getMessageCopyOptions(
});
}
if (text) {
if (href) {
options.push({
label: 'lng_context_copy_link',
icon: 'copy',
handler: () => {
copyTextToClipboard(href);
afterEffect?.();
},
});
} else if (text) {
// Detect if the user has selection in the current message
const hasSelection = Boolean((
selection?.anchorNode?.parentNode

View File

@ -46,4 +46,10 @@
.DropdownMenu {
margin-left: auto;
}
body.is-electron.is-macos #Main:not(.is-fullscreen) & {
@media (max-width: 600px) {
padding-left: 5rem;
}
}
}

View File

@ -1,6 +1,6 @@
import type { FC } from '../../lib/teact/teact';
import React, {
memo, useCallback, useEffect, useState,
memo, useCallback, useEffect, useState, useRef,
} from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
@ -28,6 +28,7 @@ import { getDayStartAt } from '../../util/dateFormat';
import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
import useLang from '../../hooks/useLang';
import useFlag from '../../hooks/useFlag';
import useElectronDrag from '../../hooks/useElectronDrag';
import useAppLayout from '../../hooks/useAppLayout';
import SearchInput from '../ui/SearchInput';
@ -515,8 +516,12 @@ const RightHeader: FC<OwnProps & StateProps> = ({
(shouldSkipTransition || shouldSkipHistoryAnimations) && 'no-transition',
);
// eslint-disable-next-line no-null/no-null
const headerRef = useRef<HTMLDivElement>(null);
useElectronDrag(headerRef);
return (
<div className="RightHeader">
<div className="RightHeader" ref={headerRef}>
<Button
className="close-button"
round

View File

@ -12,6 +12,8 @@ import './DropdownMenu.scss';
type OwnProps = {
className?: string;
trigger?: FC<{ onTrigger: () => void; isOpen?: boolean }>;
transformOriginX?: number;
transformOriginY?: number;
positionX?: 'left' | 'right';
positionY?: 'top' | 'bottom';
footer?: string;
@ -28,6 +30,8 @@ const DropdownMenu: FC<OwnProps> = ({
trigger,
className,
children,
transformOriginX,
transformOriginY,
positionX = 'left',
positionY = 'top',
footer,
@ -104,6 +108,8 @@ const DropdownMenu: FC<OwnProps> = ({
containerRef={dropdownRef}
isOpen={isOpen || Boolean(forceOpen)}
className={className || ''}
transformOriginX={transformOriginX}
transformOriginY={transformOriginY}
positionX={positionX}
positionY={positionY}
footer={footer}

View File

@ -5,17 +5,20 @@ export const APP_VERSION = process.env.APP_VERSION!;
export const RELEASE_DATETIME = process.env.RELEASE_DATETIME;
export const PRODUCTION_HOSTNAME = 'web.telegram.org';
export const DEBUG = process.env.APP_ENV !== 'production';
export const DEBUG_MORE = false;
export const STRICTERDOM_ENABLED = DEBUG;
export const PRODUCTION_URL = 'https://web.telegram.org/a';
export const IS_MOCKED_CLIENT = process.env.APP_MOCKED_CLIENT === '1';
export const IS_TEST = process.env.APP_ENV === 'test';
export const IS_PERF = process.env.APP_ENV === 'perf';
export const IS_BETA = process.env.APP_ENV === 'staging';
export const IS_ELECTRON = process.env.IS_ELECTRON;
export const DEBUG = process.env.APP_ENV !== 'production';
export const DEBUG_MORE = false;
export const STRICTERDOM_ENABLED = DEBUG && !IS_ELECTRON;
export const BETA_CHANGELOG_URL = 'https://telegra.ph/WebA-Beta-03-20';
export const ELECTRON_HOST_URL = 'https://telegram-a-host';
export const DEBUG_ALERT_MSG = 'Shoot!\nSomething went wrong, please see the error details in Dev Tools Console.';
export const DEBUG_GRAMJS = false;

33
src/electron/config.yml Normal file
View File

@ -0,0 +1,33 @@
productName: "Telegram A"
artifactName: "${productName}-${arch}-${version}.${ext}"
appId: "org.telegram.TelegramA"
extraMetadata:
main: "./dist/electron.js"
files:
- "dist"
- "package.json"
- "!dist/**/build-stats.json"
- "!dist/**/statoscope-report.html"
- "!dist/**/reference.json"
- "!dist/img-apple-*"
- "!dist/libav-*"
- "!node_modules"
directories:
buildResources: "./public"
output: "./dist-electron"
publish:
provider: "github"
owner: "Ajaxy"
repo: "telegram-tt"
win:
target: "nsis"
icon: "public/icon-electron-windows.ico"
mac:
target:
target: "default"
arch: ["x64", "arm64"]
entitlements: "public/electron-entitlements.mac.plist"
icon: "public/icon-electron-macos.icns"
linux:
category: "Community"
target: ["AppImage"]

21
src/electron/main.ts Normal file
View File

@ -0,0 +1,21 @@
import 'v8-compile-cache';
import { app, nativeImage } from 'electron';
import path from 'path';
import { createWindow, setupCloseHandlers, setupElectronActionHandlers } from './window';
import { IS_MAC_OS, IS_WINDOWS } from './utils';
app.on('ready', () => {
if (IS_MAC_OS) {
app.dock.setIcon(nativeImage.createFromPath(path.resolve(__dirname, '../public/icon-electron-macos.png')));
}
if (IS_WINDOWS) {
app.setAppUserModelId(app.getName());
}
createWindow();
setupElectronActionHandlers();
setupCloseHandlers();
});

24
src/electron/preload.ts Normal file
View File

@ -0,0 +1,24 @@
import { contextBridge, ipcRenderer } from 'electron';
import type { IpcRendererEvent } from 'electron';
import type { ElectronApi, ElectronEvent } from '../types/electron';
import { ElectronAction } from '../types/electron';
const electronApi: ElectronApi = {
isFullscreen: () => ipcRenderer.invoke(ElectronAction.GET_IS_FULLSCREEN),
installUpdate: () => ipcRenderer.invoke(ElectronAction.INSTALL_UPDATE),
handleDoubleClick: () => ipcRenderer.invoke(ElectronAction.HANDLE_DOUBLE_CLICK),
openNewWindow: (url: string) => ipcRenderer.invoke(ElectronAction.OPEN_NEW_WINDOW, url),
on: (eventName: ElectronEvent, callback) => {
const subscription = (event: IpcRendererEvent, ...args: any) => callback(...args);
ipcRenderer.on(eventName, subscription);
return () => {
ipcRenderer.removeListener(eventName, subscription);
};
},
};
contextBridge.exposeInMainWorld('electron', electronApi);

3
src/electron/utils.ts Normal file
View File

@ -0,0 +1,3 @@
export const IS_MAC_OS = process.platform === 'darwin';
export const IS_WINDOWS = process.platform === 'win32';
export const IS_LINUX = process.platform === 'linux';

203
src/electron/window.ts Normal file
View File

@ -0,0 +1,203 @@
import {
app, BrowserWindow, ipcMain, shell, systemPreferences,
} from 'electron';
import type { HandlerDetails } from 'electron';
import type { UpdateInfo } from 'electron-updater';
import { autoUpdater } from 'electron-updater';
import windowStateKeeper from 'electron-window-state';
import path from 'path';
import { ElectronAction, ElectronEvent } from '../types/electron';
import { IS_MAC_OS } from './utils';
let forceQuit = false;
let interval: NodeJS.Timer;
const windows = new Set<BrowserWindow>();
const CHECK_UPDATE_INTERVAL = 10 * 60 * 1000;
export function createWindow(url?: string) {
const windowState = windowStateKeeper({
defaultWidth: 1088,
defaultHeight: IS_MAC_OS ? 700 : 750,
});
let x;
let y;
const currentWindow = BrowserWindow.getFocusedWindow();
if (currentWindow) {
const [currentWindowX, currentWindowY] = currentWindow.getPosition();
x = currentWindowX + 24;
y = currentWindowY + 24;
} else {
x = windowState.x;
y = windowState.y;
}
let width;
let height;
if (currentWindow) {
const bounds = currentWindow.getBounds();
width = bounds.width;
height = bounds.height;
} else {
width = windowState.width;
height = windowState.height;
}
const window = new BrowserWindow({
show: false,
x,
y,
minWidth: 360,
width,
height,
title: 'Telegram A',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
devTools: process.env.APP_ENV !== 'production',
},
...(IS_MAC_OS && {
titleBarStyle: 'hidden',
trafficLightPosition: { x: 10, y: 20 },
}),
});
window.on('page-title-updated', (event: Event) => {
event.preventDefault();
});
windowState.manage(window);
window.webContents.setWindowOpenHandler((details: HandlerDetails) => {
shell.openExternal(details.url);
return { action: 'deny' };
});
window.on('enter-full-screen', () => {
window.webContents.send(ElectronEvent.FULLSCREEN_CHANGE, true);
});
window.on('leave-full-screen', () => {
window.webContents.send(ElectronEvent.FULLSCREEN_CHANGE, false);
});
window.on('close', (event) => {
if (IS_MAC_OS) {
if (forceQuit) {
app.exit(0);
forceQuit = false;
} else {
const hasExtraWindows = BrowserWindow.getAllWindows().length > 1;
if (hasExtraWindows) {
windows.delete(window);
} else {
event.preventDefault();
window.hide();
}
}
}
});
if (url) {
window.loadURL(url);
} else if (app.isPackaged) {
window.loadURL(`file://${__dirname}/index.html`);
} else {
window.loadURL('http://localhost:1234');
window.webContents.openDevTools();
}
if (!IS_MAC_OS) {
window.removeMenu();
}
window.webContents.once('dom-ready', () => {
window.show();
setupAutoUpdates(window);
});
windows.add(window);
}
function setupAutoUpdates(window: BrowserWindow) {
if (!interval) {
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.checkForUpdates();
interval = setInterval(() => autoUpdater.checkForUpdates(), CHECK_UPDATE_INTERVAL);
ipcMain.handle(ElectronAction.INSTALL_UPDATE, () => {
if (IS_MAC_OS) {
forceQuit = true;
}
return autoUpdater.quitAndInstall();
});
}
autoUpdater.on('error', (error: Error) => window.webContents.send(ElectronEvent.UPDATE_ERROR, error));
autoUpdater.on('update-downloaded', (info: UpdateInfo) => {
window.webContents.send(ElectronEvent.UPDATE_DOWNLOADED, info);
});
}
export function setupElectronActionHandlers() {
ipcMain.handle(ElectronAction.OPEN_NEW_WINDOW, (_, newWindowUrl: string) => {
createWindow(newWindowUrl);
});
ipcMain.handle(ElectronAction.GET_IS_FULLSCREEN, () => {
const currentWindow = BrowserWindow.getFocusedWindow();
currentWindow?.isFullScreen();
});
ipcMain.handle(ElectronAction.HANDLE_DOUBLE_CLICK, () => {
const currentWindow = BrowserWindow.getFocusedWindow();
const doubleClickAction = systemPreferences.getUserDefault('AppleActionOnDoubleClick', 'string');
if (doubleClickAction === 'Minimize') {
currentWindow?.minimize();
} else if (doubleClickAction === 'Maximize') {
if (!currentWindow?.isMaximized()) {
currentWindow?.maximize();
} else {
currentWindow?.unmaximize();
}
}
});
}
export function setupCloseHandlers() {
app.on('window-all-closed', () => {
if (!IS_MAC_OS) {
app.quit();
}
});
app.on('before-quit', (event) => {
if (IS_MAC_OS && !forceQuit) {
event.preventDefault();
forceQuit = true;
app.quit();
}
});
app.on('activate', () => {
const hasActiveWindow = BrowserWindow.getAllWindows().length > 0;
if (!hasActiveWindow) {
createWindow();
} else if (IS_MAC_OS) {
forceQuit = false;
const currentWindow = Array.from(windows).pop();
currentWindow?.show();
}
});
}

View File

@ -1,5 +1,6 @@
import { addActionHandler, setGlobal } from '../../index';
import { IS_ELECTRON } from '../../../config';
import { MAIN_THREAD_ID } from '../../../api/types';
import {
@ -80,7 +81,12 @@ addActionHandler('openChat', (global, actions, payload): ActionReturnType => {
addActionHandler('openChatInNewTab', (global, actions, payload): ActionReturnType => {
const { chatId, threadId = MAIN_THREAD_ID } = payload;
window.open(createMessageHashUrl(chatId, 'thread', threadId), '_blank');
const hashUrl = createMessageHashUrl(chatId, 'thread', threadId);
if (IS_ELECTRON) {
window.electron!.openNewWindow(hashUrl);
} else {
window.open(hashUrl, '_blank');
}
});
addActionHandler('openPreviousChat', (global, actions, payload): ActionReturnType => {

View File

@ -1,9 +1,10 @@
import { addCallback } from '../../../lib/teact/teactn';
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import { IS_ELECTRON } from '../../../config';
import { addActionHandler, getGlobal, setGlobal } from '../../index';
import {
IS_ANDROID, IS_IOS, IS_MAC_OS, IS_SAFARI, IS_TOUCH_ENV,
IS_ANDROID, IS_IOS, IS_MAC_OS, IS_SAFARI, IS_TOUCH_ENV, IS_WINDOWS, IS_LINUX,
} from '../../../util/windowEnvironment';
import { setLanguage } from '../../../util/langProvider';
import switchTheme from '../../../util/switchTheme';
@ -136,10 +137,17 @@ addCallback((global: GlobalState) => {
document.body.classList.add('is-android');
} else if (IS_MAC_OS) {
document.body.classList.add('is-macos');
} else if (IS_WINDOWS) {
document.body.classList.add('is-windows');
} else if (IS_LINUX) {
document.body.classList.add('is-linux');
}
if (IS_SAFARI) {
document.body.classList.add('is-safari');
}
if (IS_ELECTRON) {
document.body.classList.add('is-electron');
}
});
switchTheme(theme, selectCanAnimateInterface(global));

View File

@ -7,7 +7,7 @@ import { MAIN_THREAD_ID } from '../../../api/types';
import type { ActionReturnType, GlobalState } from '../../types';
import {
APP_VERSION, DEBUG, GLOBAL_STATE_CACHE_CUSTOM_EMOJI_LIMIT, INACTIVE_MARKER, PAGE_TITLE,
APP_VERSION, DEBUG, GLOBAL_STATE_CACHE_CUSTOM_EMOJI_LIMIT, INACTIVE_MARKER, PAGE_TITLE, IS_ELECTRON,
} from '../../../config';
import getReadableErrorText from '../../../util/getReadableErrorText';
import {
@ -580,6 +580,10 @@ addActionHandler('updateArchiveSettings', (global, actions, payload): ActionRetu
});
addActionHandler('checkAppVersion', (global): ActionReturnType => {
if (IS_ELECTRON) {
return;
}
const APP_VERSION_REGEX = /^\d+\.\d+(\.\d+)?$/;
fetch(`${APP_VERSION_URL}?${Date.now()}`)
@ -604,6 +608,15 @@ addActionHandler('checkAppVersion', (global): ActionReturnType => {
});
});
addActionHandler('setIsAppUpdateAvailable', (global, action, payload): ActionReturnType => {
global = getGlobal();
global = {
...global,
isUpdateAvailable: Boolean(payload),
};
setGlobal(global);
});
addActionHandler('afterHangUp', (global): ActionReturnType => {
if (!selectTabState(global, getCurrentTabId()).multitabNextAction) return;
reestablishMasterToSelf();

View File

@ -1593,6 +1593,7 @@ export interface ActionPayloads {
openLimitReachedModal: { limit: ApiLimitTypeWithModal } & WithTabId;
closeLimitReachedModal: WithTabId | undefined;
checkAppVersion: undefined;
setIsAppUpdateAvailable: boolean;
setGlobalSearchClosing: ({
isClosing?: boolean;
} & WithTabId) | undefined;

View File

@ -5,7 +5,7 @@ import type { ApiChat, ApiUser } from '../api/types';
import type { MenuItemContextAction } from '../components/ui/ListItem';
import { IS_OPEN_IN_NEW_TAB_SUPPORTED } from '../util/windowEnvironment';
import { SERVICE_NOTIFICATIONS_USER_ID } from '../config';
import { IS_ELECTRON, SERVICE_NOTIFICATIONS_USER_ID } from '../config';
import {
isChatArchived, getCanDeleteChat, isUserId, isChatChannel, isChatGroup,
} from '../global/helpers';
@ -54,7 +54,7 @@ const useChatContextActions = ({
} = getActions();
const actionOpenInNewTab = IS_OPEN_IN_NEW_TAB_SUPPORTED && {
title: 'Open in new tab',
title: IS_ELECTRON ? 'Open in new window' : 'Open in new tab',
icon: 'open-in-new-tab',
handler: () => {
openChatInNewTab({ chatId: chat.id });

View File

@ -25,6 +25,7 @@ const useContextMenuHandlers = (
) => {
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const [contextMenuPosition, setContextMenuPosition] = useState<IAnchorPosition | undefined>(undefined);
const [contextMenuTarget, setContextMenuTarget] = useState<HTMLElement | undefined>(undefined);
const handleBeforeContextMenu = useCallback((e: React.MouseEvent) => {
if (!isMenuDisabled && e.button === 2) {
@ -50,6 +51,7 @@ const useContextMenuHandlers = (
setIsContextMenuOpen(true);
setContextMenuPosition({ x: e.clientX, y: e.clientY });
setContextMenuTarget(e.target as HTMLElement);
}, [isMenuDisabled, shouldDisableOnLink, contextMenuPosition]);
const handleContextMenuClose = useCallback(() => {
@ -151,6 +153,7 @@ const useContextMenuHandlers = (
return {
isContextMenuOpen,
contextMenuPosition,
contextMenuTarget,
handleBeforeContextMenu,
handleContextMenu,
handleContextMenuClose,

View File

@ -0,0 +1,69 @@
import type { RefObject } from 'react';
import { useEffect, useRef } from '../lib/teact/teact';
import { IS_ELECTRON } from '../config';
import { IS_MAC_OS } from '../util/windowEnvironment';
const DRAG_DISTANCE_THRESHOLD = 5;
const useElectronDrag = (ref: RefObject<HTMLDivElement>) => {
const isDragging = useRef(false);
const x = useRef(window.screenX);
const y = useRef(window.screenY);
const distance = useRef(0);
useEffect(() => {
const element: HTMLDivElement | null = ref.current;
if (!element || !(IS_ELECTRON && IS_MAC_OS)) return undefined;
const handleClick = (event: MouseEvent) => {
distance.current = 0;
if (isDragging.current) {
event.preventDefault();
event.stopPropagation();
isDragging.current = false;
}
};
const handleMousedown = (event: MouseEvent) => {
if (isDragging.current) {
event.preventDefault();
event.stopPropagation();
}
};
const handleDrag = (event: MouseEvent) => {
if (event.buttons === 1) {
distance.current += Math.sqrt((x.current - window.screenX) ** 2 + (y.current - window.screenY) ** 2);
x.current = window.screenX;
y.current = window.screenY;
if (!isDragging.current && distance.current > DRAG_DISTANCE_THRESHOLD) {
isDragging.current = true;
}
}
};
const handleDoubleClick = (event: MouseEvent) => {
if (event.currentTarget === event.target) {
window.electron?.handleDoubleClick();
}
};
element.addEventListener('click', handleClick);
element.addEventListener('mousedown', handleMousedown);
element.addEventListener('mousemove', handleDrag);
element.addEventListener('dblclick', handleDoubleClick);
return () => {
element.removeEventListener('click', handleClick);
element.removeEventListener('mouseup', handleMousedown);
element.removeEventListener('mousemove', handleDrag);
element.removeEventListener('dblclick', handleDoubleClick);
};
}, [ref]);
};
export default useElectronDrag;

View File

@ -1,6 +1,8 @@
import { useLayoutEffect, useState, useEffect } from '../lib/teact/teact';
import { IS_IOS } from '../util/windowEnvironment';
import { ElectronEvent } from '../types/electron';
type RefType = {
current: HTMLVideoElement | null;
};
@ -79,11 +81,16 @@ export const useFullscreenStatus = () => {
setIsFullscreen(checkIfFullscreen());
};
const removeElectronListener = window.electron?.on(ElectronEvent.FULLSCREEN_CHANGE, setIsFullscreen);
window.electron?.isFullscreen().then(setIsFullscreen);
document.addEventListener('fullscreenchange', listener, false);
document.addEventListener('webkitfullscreenchange', listener, false);
document.addEventListener('mozfullscreenchange', listener, false);
return () => {
removeElectronListener?.();
document.removeEventListener('fullscreenchange', listener, false);
document.removeEventListener('webkitfullscreenchange', listener, false);
document.removeEventListener('mozfullscreenchange', listener, false);

26
src/types/electron.ts Normal file
View File

@ -0,0 +1,26 @@
export enum ElectronEvent {
FULLSCREEN_CHANGE = 'fullscreen-change',
UPDATE_ERROR = 'update-error',
UPDATE_DOWNLOADED = 'update-downloaded',
}
export enum ElectronAction {
GET_IS_FULLSCREEN = 'get-is-fullscreen',
INSTALL_UPDATE = 'install-update',
HANDLE_DOUBLE_CLICK = 'handle-double-click',
OPEN_NEW_WINDOW = 'open-new-window',
}
export interface ElectronApi {
isFullscreen: () => Promise<boolean>;
installUpdate: () => Promise<void>;
handleDoubleClick: () => Promise<void>;
openNewWindow: (url: string) => Promise<void>;
on: (eventName: ElectronEvent, callback: any) => VoidFunction;
}
declare global {
interface Window {
electron?: ElectronApi;
}
}

View File

@ -1,3 +1,5 @@
import { IS_ELECTRON, ELECTRON_HOST_URL } from '../config';
// eslint-disable-next-line no-restricted-globals
const cacheApi = self.caches;
@ -24,7 +26,9 @@ export async function fetch(
try {
// To avoid the error "Request scheme 'webdocument' is unsupported"
const request = new Request(key.replace(/:/g, '_'));
const request = IS_ELECTRON
? `${ELECTRON_HOST_URL}/${key.replace(/:/g, '_')}`
: new Request(key.replace(/:/g, '_'));
const cache = await cacheApi.open(cacheName);
const response = await cache.match(request);
if (!response) {
@ -82,7 +86,9 @@ export async function save(cacheName: string, key: string, data: AnyLiteral | Bl
? data
: JSON.stringify(data);
// To avoid the error "Request scheme 'webdocument' is unsupported"
const request = new Request(key.replace(/:/g, '_'));
const request = IS_ELECTRON
? `${ELECTRON_HOST_URL}/${key.replace(/:/g, '_')}`
: new Request(key.replace(/:/g, '_'));
const response = new Response(cacheData);
const cache = await cacheApi.open(cacheName);
await cache.put(request, response);

View File

@ -8,7 +8,7 @@ import {
} from '../api/types';
import {
DEBUG, MEDIA_CACHE_DISABLED, MEDIA_CACHE_NAME, MEDIA_CACHE_NAME_AVATARS,
DEBUG, MEDIA_CACHE_DISABLED, MEDIA_CACHE_NAME, MEDIA_CACHE_NAME_AVATARS, IS_ELECTRON, ELECTRON_HOST_URL,
} from '../config';
import { callApi, cancelApiProgress } from '../api/gramjs';
import * as cacheApi from './cacheApi';
@ -27,7 +27,7 @@ const asCacheApiType = {
[ApiMediaFormat.Stream]: undefined,
};
const PROGRESSIVE_URL_PREFIX = './progressive/';
const PROGRESSIVE_URL_PREFIX = `${IS_ELECTRON ? ELECTRON_HOST_URL : '.'}/progressive/`;
const URL_DOWNLOAD_PREFIX = './download/';
const memoryCache = new Map<string, ApiPreparedMedia>();

View File

@ -4,7 +4,9 @@ import type {
} from '../api/types';
import { ApiMediaFormat } from '../api/types';
import { renderActionMessageText } from '../components/common/helpers/renderActionMessageText';
import { APP_NAME, DEBUG, IS_TEST } from '../config';
import {
APP_NAME, DEBUG, IS_ELECTRON, IS_TEST,
} from '../config';
import { getActions, getGlobal, setGlobal } from '../global';
import {
getChatAvatarHash,
@ -44,7 +46,8 @@ function getDeviceToken(subscription: PushSubscription) {
}
function checkIfPushSupported() {
if (!IS_SERVICE_WORKER_SUPPORTED) return false;
if (!IS_SERVICE_WORKER_SUPPORTED || IS_ELECTRON) return false;
if (!('showNotification' in ServiceWorkerRegistration.prototype)) {
if (DEBUG) {
// eslint-disable-next-line no-console
@ -70,6 +73,7 @@ function checkIfPushSupported() {
}
return false;
}
return true;
}
@ -446,7 +450,7 @@ export async function notifyAboutMessage({
if (!checkIfShouldNotify(chat, message)) return;
const areNotificationsSupported = checkIfNotificationsSupported();
if (!hasWebNotifications || !areNotificationsSupported) {
if (!message.isSilent && !isReaction) {
if (!message.isSilent && !isReaction && !IS_ELECTRON) {
// Only play sound if web notifications are disabled
playNotifySoundDebounced(String(message.id) || chat.id);
}
@ -520,7 +524,7 @@ export async function notifyAboutMessage({
// Play sound when notification is displayed
notification.onshow = () => {
// TODO Update when reaction badges are implemented
if (isReaction || message.isSilent) return;
if (isReaction || message.isSilent || IS_ELECTRON) return;
playNotifySoundDebounced(String(message.id) || chat.id);
};
}

View File

@ -1,4 +1,6 @@
import { APP_VERSION, DEBUG, IS_MOCKED_CLIENT } from '../config';
import {
APP_VERSION, DEBUG, IS_MOCKED_CLIENT, IS_ELECTRON,
} from '../config';
import { getGlobal } from '../global';
import { hasStoredSession } from './sessions';
@ -25,7 +27,7 @@ const saveSync = (authed: boolean) => {
let lastTimeout: NodeJS.Timeout | undefined;
export const forceWebsync = (authed: boolean) => {
if (IS_MOCKED_CLIENT) return undefined;
if (IS_MOCKED_CLIENT || IS_ELECTRON) return undefined;
const currentTs = getTs();
const { canRedirect, ts } = JSON.parse(localStorage.getItem(WEBSYNC_KEY) || '{}');
@ -67,13 +69,13 @@ export const forceWebsync = (authed: boolean) => {
};
export function stopWebsync() {
if (DEBUG) return;
if (DEBUG || IS_ELECTRON) return;
if (lastTimeout) clearTimeout(lastTimeout);
}
export function startWebsync() {
if (DEBUG) {
if (DEBUG || IS_ELECTRON) {
return;
}

View File

@ -1,5 +1,6 @@
import {
IS_TEST,
IS_ELECTRON,
SUPPORTED_VIDEO_CONTENT_TYPES,
VIDEO_MOV_TYPE,
CONTENT_TYPES_WITH_PREVIEW,
@ -37,6 +38,8 @@ export function getPlatform() {
export const IS_PRODUCTION_HOST = window.location.host === PRODUCTION_HOSTNAME;
export const PLATFORM_ENV = getPlatform();
export const IS_MAC_OS = PLATFORM_ENV === 'macOS';
export const IS_WINDOWS = PLATFORM_ENV === 'Windows';
export const IS_LINUX = PLATFORM_ENV === 'Linux';
export const IS_IOS = PLATFORM_ENV === 'iOS';
export const IS_ANDROID = PLATFORM_ENV === 'Android';
export const IS_MOBILE = IS_IOS || IS_ANDROID;
@ -57,6 +60,8 @@ export const IS_PWA = (
|| document.referrer.includes('android-app://')
);
export const IS_APP = IS_PWA || IS_ELECTRON;
export const IS_TOUCH_ENV = window.matchMedia('(pointer: coarse)').matches;
export const IS_VOICE_RECORDING_SUPPORTED = Boolean(
window.navigator.mediaDevices && 'getUserMedia' in window.navigator.mediaDevices && (

View File

@ -26,7 +26,8 @@
"tests",
"plugins",
"dev",
"webpack.config.js",
"webpack.config.ts",
"webpack-electron.config.ts",
"babel.config.js",
]
}

View File

@ -0,0 +1,40 @@
import path from 'path';
import { EnvironmentPlugin } from 'webpack';
const { APP_ENV = 'production' } = process.env;
export default {
mode: 'production',
target: 'node',
entry: {
electron: './src/electron/main.ts',
preload: './src/electron/preload.ts',
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dist'),
},
resolve: {
extensions: ['.ts', '.js'],
},
plugins: [
new EnvironmentPlugin({ APP_ENV }),
],
module: {
rules: [{
test: /\.(ts|tsx|js)$/,
loader: 'babel-loader',
exclude: /node_modules/,
}],
},
externals: {
electron: 'require("electron")',
},
};

View File

@ -1,25 +1,29 @@
const path = require('path');
const fs = require('fs');
const dotenv = require('dotenv');
import path from 'path';
import fs from 'fs';
import dotenv from 'dotenv';
const {
import {
DefinePlugin,
EnvironmentPlugin,
ProvidePlugin,
ContextReplacementPlugin,
NormalModuleReplacementPlugin,
} = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const { GitRevisionPlugin } = require('git-revision-webpack-plugin');
const StatoscopeWebpackPlugin = require('@statoscope/webpack-plugin').default;
const WebpackContextExtension = require('./dev/webpackContextExtension');
const appVersion = require('./package.json').version;
} from 'webpack';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import { GitRevisionPlugin } from 'git-revision-webpack-plugin';
import StatoscopeWebpackPlugin from '@statoscope/webpack-plugin';
import type { Configuration, Compiler } from 'webpack';
import 'webpack-dev-server';
import { version as appVersion } from './package.json';
const {
HEAD,
APP_ENV = 'production',
APP_MOCKED_CLIENT = '',
IS_ELECTRON,
} = process.env;
dotenv.config();
@ -33,7 +37,10 @@ const {
APP_TITLE = DEFAULT_APP_TITLE,
} = process.env;
module.exports = (_env, { mode = 'production' }) => {
export default function createConfig(
_: any,
{ mode = 'production' }: { mode: 'none' | 'development' | 'production' },
): Configuration {
return {
mode,
entry: './src/index.tsx',
@ -146,13 +153,13 @@ module.exports = (_env, { mode = 'production' }) => {
plugins: [
...(APP_ENV === 'staging' ? [{
apply: (compiler) => {
apply: (compiler: Compiler) => {
compiler.hooks.compile.tap('Before Compilation', async () => {
try {
const stats = await fetch(STATOSCOPE_REFERENCE_URL).then((res) => res.text());
fs.writeFileSync(path.resolve('./public/reference.json'), stats);
isReferenceFetched = true;
} catch (err) {
} catch (err: any) {
// eslint-disable-next-line no-console
console.warn('Failed to fetch reference statoscope stats: ', err.message);
}
@ -187,6 +194,7 @@ module.exports = (_env, { mode = 'production' }) => {
// eslint-disable-next-line no-null/no-null
APP_NAME: null,
APP_VERSION: appVersion,
IS_ELECTRON: false,
APP_TITLE,
RELEASE_DATETIME: Date.now(),
TELEGRAM_T_API_ID: undefined,
@ -212,14 +220,14 @@ module.exports = (_env, { mode = 'production' }) => {
saveStatsTo: path.resolve('./public/build-stats.json'),
normalizeStats: true,
open: 'file',
extensions: [new WebpackContextExtension()],
extensions: [new WebpackContextExtension()], // eslint-disable-line @typescript-eslint/no-use-before-define
...(APP_ENV === 'staging' && isReferenceFetched && {
additionalStats: ['./public/reference.json'],
}),
}),
],
devtool: 'source-map',
devtool: APP_ENV === 'production' && IS_ELECTRON ? undefined : 'source-map',
...(APP_ENV !== 'production' && {
optimization: {
@ -227,11 +235,30 @@ module.exports = (_env, { mode = 'production' }) => {
},
}),
};
};
}
function getGitMetadata() {
const gitRevisionPlugin = new GitRevisionPlugin();
const branch = HEAD || gitRevisionPlugin.branch();
const commit = gitRevisionPlugin.commithash().substring(0, 7);
const commit = gitRevisionPlugin.commithash()?.substring(0, 7);
return { branch, commit };
}
class WebpackContextExtension {
context: string;
constructor() {
this.context = '';
}
handleCompiler(compiler: Compiler) {
this.context = compiler.context;
}
getExtension() {
return {
descriptor: { name: 'custom-webpack-extension-context', version: '1.0.0' },
payload: { context: this.context },
};
}
}