├── .eslintrc.json ├── .gitignore ├── .gitlab-ci.yml ├── .idea ├── .gitignore ├── airmessage-react.iml ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── compiler.xml ├── inspectionProfiles │ └── Project_Default.xml ├── jsLibraryMappings.xml ├── misc.xml ├── modules.xml └── vcs.xml ├── .sentryclirc ├── LICENSE ├── README.md ├── README └── windows-web.png ├── index.d.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── public ├── favicon-128.png ├── favicon-152.png ├── favicon-167.png ├── favicon-180.png ├── favicon-192.png ├── favicon-196.png ├── favicon-32.png ├── index.css ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── components │ ├── LoginContext.tsx │ ├── Markdown.tsx │ ├── Onboarding.tsx │ ├── SignInGate.tsx │ ├── WidthContainer.tsx │ ├── calling │ │ ├── CallNotificationIncoming.tsx │ │ ├── CallNotificationOutgoing.tsx │ │ └── CallOverlay.tsx │ ├── control │ │ ├── AppTheme.tsx │ │ └── SnackbarProvider.tsx │ ├── icon │ │ ├── BorderedCloseIcon.tsx │ │ ├── CreateConversationIcon.tsx │ │ ├── DiscordIcon.tsx │ │ ├── MessageCheckIcon.tsx │ │ ├── PushIcon.tsx │ │ ├── RedditIcon.tsx │ │ ├── TapbackDislikeIcon.tsx │ │ ├── TapbackEmphasisIcon.tsx │ │ ├── TapbackLaughIcon.tsx │ │ ├── TapbackLikeIcon.tsx │ │ ├── TapbackLoveIcon.tsx │ │ └── TapbackQuestionIcon.tsx │ ├── logo │ │ ├── AirMessageLogo.module.css │ │ └── AirMessageLogo.tsx │ ├── messaging │ │ ├── create │ │ │ ├── DetailCreate.tsx │ │ │ ├── DetailCreateAddressButton.tsx │ │ │ ├── DetailCreateDirectSendButton.tsx │ │ │ ├── DetailCreateListSubheader.tsx │ │ │ └── DetailCreateSelectionChip.tsx │ │ ├── detail │ │ │ ├── DetailError.module.css │ │ │ ├── DetailError.tsx │ │ │ ├── DetailLoading.tsx │ │ │ └── DetailWelcome.tsx │ │ ├── dialog │ │ │ ├── ChangelogDialog.tsx │ │ │ ├── FaceTimeLinkDialog.tsx │ │ │ ├── FeedbackDialog.tsx │ │ │ ├── RemoteUpdateDialog.tsx │ │ │ ├── SignOutDialog.tsx │ │ │ └── UpdateRequiredDialog.tsx │ │ ├── master │ │ │ ├── ConnectionBanner.tsx │ │ │ ├── DetailFrame.tsx │ │ │ ├── GroupAvatar.module.css │ │ │ ├── GroupAvatar.tsx │ │ │ ├── ListConversation.tsx │ │ │ ├── Messaging.tsx │ │ │ ├── Sidebar.module.css │ │ │ ├── Sidebar.tsx │ │ │ └── SidebarBanner.tsx │ │ └── thread │ │ │ ├── DetailThread.tsx │ │ │ ├── MessageInput.tsx │ │ │ ├── MessageList.tsx │ │ │ ├── item │ │ │ ├── ConversationActionLine.tsx │ │ │ ├── ConversationActionParticipant.tsx │ │ │ ├── ConversationActionRename.tsx │ │ │ ├── Message.tsx │ │ │ └── bubble │ │ │ │ ├── MessageBubbleDownloadable.tsx │ │ │ │ ├── MessageBubbleImage.tsx │ │ │ │ ├── MessageBubbleText.tsx │ │ │ │ ├── MessageBubbleWrapper.tsx │ │ │ │ ├── StickerStack.tsx │ │ │ │ ├── TapbackChip.tsx │ │ │ │ └── TapbackRow.tsx │ │ │ └── queue │ │ │ ├── QueuedAttachment.tsx │ │ │ ├── QueuedAttachmentGeneric.tsx │ │ │ └── QueuedAttachmentImage.tsx │ └── skeleton │ │ └── ConversationSkeleton.tsx ├── connection │ ├── comm5 │ │ ├── airPacker.ts │ │ ├── airUnpacker.ts │ │ ├── clientComm5.ts │ │ ├── clientProtocol4.ts │ │ ├── clientProtocol5.ts │ │ └── protocolManager.ts │ ├── communicationsManager.ts │ ├── connect │ │ ├── dataProxyConnect.ts │ │ ├── nht.ts │ │ └── webSocketCloseEventCodes.ts │ ├── connectionManager.ts │ ├── dataProxy.ts │ └── transferAccumulator.ts ├── constants.ts ├── data │ ├── appleConstants.ts │ ├── blocks.ts │ ├── callEvent.ts │ ├── conversationTarget.ts │ ├── fileDownloadResult.ts │ ├── linkConstants.ts │ ├── newMessageUser.ts │ ├── paletteSpecifier.ts │ ├── releaseInfo.ts │ ├── serverUpdateData.ts │ ├── stateCodes.ts │ └── unsubscribeCallback.ts ├── index.tsx ├── interface │ ├── notification │ │ ├── browserNotificationUtils.ts │ │ └── notificationUtils.ts │ ├── people │ │ └── peopleUtils.ts │ └── platform │ │ ├── browserPlatformUtils.ts │ │ └── platformUtils.ts ├── resources │ ├── audio │ │ ├── message_in.wav │ │ ├── message_out.wav │ │ ├── notification.wav │ │ └── tapback.wav │ ├── icons │ │ ├── control-send.svg │ │ ├── logo-airmessage-light.svg │ │ ├── logo-google.svg │ │ ├── tile-airmessage.svg │ │ └── tile-mac.svg │ └── text │ │ └── changelog.md ├── secrets.default.ts ├── state │ ├── conversationState.ts │ ├── localMessageCache.ts │ ├── peopleState.tsx │ └── remoteLibProvider.tsx └── util │ ├── addressHelper.ts │ ├── arrayUtils.ts │ ├── authUtils.ts │ ├── avatarUtils.ts │ ├── browserUtils.ts │ ├── cancellablePromise.ts │ ├── conversationUtils.ts │ ├── dateUtils.ts │ ├── emitterPromiseTuple.ts │ ├── encodingUtils.ts │ ├── encryptionUtils.ts │ ├── eventEmitter.ts │ ├── hashUtils.ts │ ├── hookUtils.ts │ ├── installationUtils.ts │ ├── languageUtils.ts │ ├── messageFlow.ts │ ├── promiseTimeout.ts │ ├── resolveablePromise.ts │ ├── resolveablePromiseTimeout.ts │ ├── secureStorageUtils.ts │ ├── soundUtils.ts │ ├── taskQueue.ts │ └── versionUtils.ts ├── test └── util │ └── arrayUtils.test.ts ├── tsconfig.json └── webpack.config.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:react-hooks/recommended" 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "parserOptions": { 14 | "ecmaFeatures": { 15 | "jsx": true 16 | }, 17 | "ecmaVersion": 12, 18 | "sourceType": "module" 19 | }, 20 | "plugins": [ 21 | "react", 22 | "@typescript-eslint" 23 | ], 24 | "settings": { 25 | "react": { 26 | "version": "detect" 27 | } 28 | }, 29 | "rules": { 30 | "quotes": [ 31 | "error", 32 | "double", 33 | { 34 | "avoidEscape": true, 35 | "allowTemplateLiterals": true 36 | } 37 | ], 38 | "semi": "off", 39 | "no-inner-declarations": "off", 40 | "no-mixed-spaces-and-tabs": [ 41 | "error", 42 | "smart-tabs" 43 | ], 44 | "no-constant-condition": [ 45 | "warn", 46 | {"checkLoops": false} 47 | ], 48 | "@typescript-eslint/semi": ["warn", "always"], 49 | "@typescript-eslint/no-explicit-any": "off", 50 | "@typescript-eslint/explicit-module-boundary-types": "off", 51 | "@typescript-eslint/no-unused-vars": "off", 52 | "@typescript-eslint/no-non-null-assertion": "off", 53 | "@typescript-eslint/no-inferrable-types": "off", 54 | "@typescript-eslint/no-empty-function": "off", 55 | "react/display-name": "off", 56 | "react/prop-types": "off", 57 | "@typescript-eslint/no-var-requires": "off", 58 | "jsx-indent-props": "off" 59 | } 60 | } -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: node:latest 2 | 3 | cache: 4 | paths: 5 | - node_modules # Cache node_modules for better performance 6 | 7 | before_script: 8 | - npm install 9 | 10 | pages: 11 | stage: deploy 12 | script: 13 | - mkdir src/secure 14 | - base64 -d <<< $SECURE_CONFIG > src/secure/config.ts 15 | - CI= npm run publish 16 | - rm -rf public 17 | - mv build public 18 | artifacts: 19 | paths: 20 | - public # GitLab pages serves from a 'public' directory 21 | only: 22 | - master # Run on master branch -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /.idea/airmessage-react.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.sentryclirc: -------------------------------------------------------------------------------- 1 | [defaults] 2 | project=airmessage-web 3 | org=airmessage -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AirMessage for web 2 | 3 | ![AirMessage running on Microsoft Edge](README/windows-web.png) 4 | 5 | AirMessage lets people use iMessage on the devices they like. 6 | **AirMessage for web** brings iMessage to modern web browsers over a WebSocket proxy. 7 | Production builds are hosted on [web.airmessage.org](https://web.airmessage.org). 8 | 9 | Other AirMessage repositories: 10 | [Server](https://github.com/airmessage/airmessage-server) | 11 | [Android](https://github.com/airmessage/airmessage-android) | 12 | [Connect (community)](https://github.com/airmessage/airmessage-connect-java) 13 | 14 | ## Getting started 15 | 16 | To build AirMessage for web, you will need [Node.js](https://nodejs.org). 17 | 18 | AirMessage for web uses [React](https://reactjs.org) and [TypeScript](https://www.typescriptlang.org). If you're not familiar with these tools, they both have great introductory guides: 19 | - [React - Getting started](https://reactjs.org/docs/getting-started.html) 20 | - [TypeScript for JavaScript Programmers](https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes.html) 21 | 22 | AirMessage for web uses a configuration file to associate with online services like Firebase and Sentry. 23 | The app will not build without a valid configuration, so to get started quickly, you can copy the `src/secrets.default.ts` file to `src/secrets.ts` to use a pre-configured Firebase project, or you may provide your own Firebase configuration file. 24 | 25 | To launch a development server, run `npm start`. To build a production-optimized bundle, run `npm run build`. 26 | 27 | ## Building and running for AirMessage Connect 28 | 29 | In order to help developers get started quickly, we host a separate open-source version of AirMessage Connect at `connect-open.airmessage.org`. 30 | The default configuration is pre-configured to authenticate and connect to this server. 31 | Since this version of AirMessage Connect is hosted in a separate environment from official servers, you will have to be running a version of AirMessage Server that also connects to the same AirMessage Connect server. 32 | 33 | We kindly ask that you do not use AirMessage's official Connect servers with any unofficial builds of AirMessage-compatible software. 34 | 35 | --- 36 | 37 | Thank you for your interest in contributing to AirMessage! 38 | You're helping to shape the future of an open, secure messaging market. 39 | Should you have any questions, comments, or concerns, please shoot an email to [hello@airmessage.org](mailto:hello@airmessage.org). 40 | -------------------------------------------------------------------------------- /README/windows-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airmessage/airmessage-web/81c65b4068f56db25cb608ae9378c32ae4fe79fb/README/windows-web.png -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.module.css" { 2 | const classes: { [key: string]: string }; 3 | export default classes; 4 | } 5 | 6 | declare module "*.svg" { 7 | const content: any; 8 | export default content; 9 | } 10 | 11 | declare module "*.wav" { 12 | const content: any; 13 | export default content; 14 | } 15 | 16 | declare module "*.md" { 17 | const content: string; 18 | export default content; 19 | } 20 | 21 | declare const WPEnv: { 22 | ENVIRONMENT: "production" | "development"; 23 | PACKAGE_VERSION: string; 24 | RELEASE_HASH: string | undefined; 25 | BUILD_DATE: number; 26 | WINRT: boolean; 27 | }; -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 3 | module.exports = { 4 | preset: "ts-jest", 5 | testEnvironment: "jsdom", 6 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "airmessage-react", 3 | "version": "1.4.9", 4 | "author": "Tagavari", 5 | "description": "iMessage for the web", 6 | "private": true, 7 | "sideEffects": [ 8 | "**/*.css", 9 | "./{browser,windows/web}/init.ts" 10 | ], 11 | "scripts": { 12 | "start": "webpack serve --open", 13 | "start-secure": "webpack serve --open --env secure", 14 | "build": "webpack build", 15 | "test": "jest" 16 | }, 17 | "devDependencies": { 18 | "@testing-library/react": "^13.1.1", 19 | "@types/bytebuffer": "^5.0.42", 20 | "@types/gapi": "^0.0.43", 21 | "@types/gapi.people": "^1.0.5", 22 | "@types/jest": "^29.1.2", 23 | "@types/luxon": "^3.0.0", 24 | "@types/pako": "^2.0.0", 25 | "@types/react": "^18.0.5", 26 | "@types/react-dom": "^18.0.1", 27 | "@types/spark-md5": "^3.0.2", 28 | "@types/ua-parser-js": "^0.7.35", 29 | "@types/uuid": "^8.3.0", 30 | "@typescript-eslint/eslint-plugin": "^5.3.0", 31 | "@typescript-eslint/parser": "^5.3.0", 32 | "copy-webpack-plugin": "^11.0.0", 33 | "css-loader": "^6.2.0", 34 | "eslint": "^8.5.0", 35 | "eslint-plugin-react": "^7.22.0", 36 | "eslint-plugin-react-hooks": "^4.3.0", 37 | "fork-ts-checker-webpack-plugin": "^7.0.0", 38 | "jest": "^29.2.0", 39 | "jest-environment-jsdom": "^29.2.0", 40 | "source-map-loader": "^4.0.0", 41 | "style-loader": "^3.0.0", 42 | "ts-jest": "^29.0.3", 43 | "ts-loader": "^9.1.2", 44 | "typescript": "^4.1.5", 45 | "webpack": "^5.30.0", 46 | "webpack-cli": "^4.5.0", 47 | "webpack-dev-server": "^4.1.0", 48 | "workbox-webpack-plugin": "^6.1.1" 49 | }, 50 | "dependencies": { 51 | "@emotion/react": "^11.4.1", 52 | "@emotion/styled": "^11.3.0", 53 | "@mui/icons-material": "^5.0.0", 54 | "@mui/material": "^5.0.0", 55 | "@sentry/react": "^7.0.0", 56 | "bytebuffer": "^5.0.1", 57 | "firebase": "^9.0.1", 58 | "js-base64": "^3.6.1", 59 | "libphonenumber-js": "^1.9.15", 60 | "linkify-react": "^4.0.2", 61 | "linkifyjs": "^4.0.2", 62 | "luxon": "^3.0.1", 63 | "markdown-to-jsx": "^7.1.1", 64 | "pako": "^2.0.3", 65 | "react": "^18.0.0", 66 | "react-dom": "^18.0.0", 67 | "react-transition-group": "^4.4.2", 68 | "spark-md5": "^3.0.1", 69 | "ua-parser-js": "^1.0.2", 70 | "uuid": "^9.0.0" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /public/favicon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airmessage/airmessage-web/81c65b4068f56db25cb608ae9378c32ae4fe79fb/public/favicon-128.png -------------------------------------------------------------------------------- /public/favicon-152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airmessage/airmessage-web/81c65b4068f56db25cb608ae9378c32ae4fe79fb/public/favicon-152.png -------------------------------------------------------------------------------- /public/favicon-167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airmessage/airmessage-web/81c65b4068f56db25cb608ae9378c32ae4fe79fb/public/favicon-167.png -------------------------------------------------------------------------------- /public/favicon-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airmessage/airmessage-web/81c65b4068f56db25cb608ae9378c32ae4fe79fb/public/favicon-180.png -------------------------------------------------------------------------------- /public/favicon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airmessage/airmessage-web/81c65b4068f56db25cb608ae9378c32ae4fe79fb/public/favicon-192.png -------------------------------------------------------------------------------- /public/favicon-196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airmessage/airmessage-web/81c65b4068f56db25cb608ae9378c32ae4fe79fb/public/favicon-196.png -------------------------------------------------------------------------------- /public/favicon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airmessage/airmessage-web/81c65b4068f56db25cb608ae9378c32ae4fe79fb/public/favicon-32.png -------------------------------------------------------------------------------- /public/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@100;300;400;500;700;900&display=swap'); 2 | @import url('https://fonts.googleapis.com/css2?family=Manrope:wght@700&display=swap'); 3 | 4 | html, body, #root { 5 | width: 100%; 6 | height: 100%; 7 | } 8 | 9 | body { 10 | margin: 0; 11 | /* font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 12 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 13 | sans-serif; */ 14 | -webkit-font-smoothing: antialiased; 15 | -moz-osx-font-smoothing: grayscale; 16 | } 17 | 18 | code { 19 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 20 | } -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | AirMessage 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airmessage/airmessage-web/81c65b4068f56db25cb608ae9378c32ae4fe79fb/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airmessage/airmessage-web/81c65b4068f56db25cb608ae9378c32ae4fe79fb/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "AirMessage", 3 | "name": "AirMessage", 4 | "icons": [ 5 | { 6 | "src": "logo192.png", 7 | "type": "image/png", 8 | "sizes": "192x192" 9 | }, 10 | { 11 | "src": "logo512.png", 12 | "type": "image/png", 13 | "sizes": "512x512" 14 | } 15 | ], 16 | "start_url": ".", 17 | "display": "standalone", 18 | "theme_color": "#448AFF", 19 | "background_color": "#FFFFFF" 20 | } -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/components/LoginContext.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default React.createContext<{ 4 | signOut: () => void; 5 | }>({ 6 | signOut: () => { throw new Error("signOut not defined"); } 7 | }); -------------------------------------------------------------------------------- /src/components/Markdown.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactMarkdown, {MarkdownToJSX} from "markdown-to-jsx"; 3 | import {Link, makeStyles, styled, Typography} from "@mui/material"; 4 | 5 | const SpacedListItem = styled("li")(({ theme }) => ({ 6 | marginTop: theme.spacing(1), 7 | })); 8 | 9 | const options: MarkdownToJSX.Options = { 10 | overrides: { 11 | h1: { 12 | component: Typography, 13 | props: { 14 | gutterBottom: true, 15 | variant: "h5", 16 | }, 17 | }, 18 | h2: { component: Typography, props: { gutterBottom: true, variant: "h6" } }, 19 | h3: { component: Typography, props: { gutterBottom: true, variant: "subtitle1" } }, 20 | h4: { 21 | component: Typography, 22 | props: { gutterBottom: true, variant: "caption", paragraph: true }, 23 | }, 24 | span: { component: Typography }, 25 | p: { component: Typography, props: { paragraph: true } }, 26 | a: { component: Link, props: { target: "_blank", rel: "noopener"} }, 27 | li: { component: SpacedListItem }, 28 | }, 29 | }; 30 | 31 | export default function Markdown(props: {markdown: string}) { 32 | return {props.markdown}; 33 | } -------------------------------------------------------------------------------- /src/components/Onboarding.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import {Box, Button, Stack, styled, Typography} from "@mui/material"; 4 | import iconAirMessage from "shared/resources/icons/tile-airmessage.svg"; 5 | import iconMac from "shared/resources/icons/tile-mac.svg"; 6 | import iconGoogle from "shared/resources/icons/logo-google.svg"; 7 | 8 | import AirMessageLogo from "shared/components/logo/AirMessageLogo"; 9 | 10 | const OnboardingColumn = styled(Stack)({ 11 | maxWidth: 400 12 | }); 13 | const InstructionIconImg = styled("img")({ 14 | width: 64, 15 | height: 64 16 | }); 17 | 18 | export default function Onboarding(props: { 19 | onSignInGoogle?: (() => void) 20 | }) { 21 | return ( 22 | 26 | 27 | 28 | 29 | 30 | 31 | 34 | Use iMessage on any computer with AirMessage 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 1. Set up your server 44 | A server installed on a Mac computer is required to route your messages for you. 45 | Visit airmessage.org on a Mac computer to download. 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 2. Connect your account 54 | Sign in with your account to get your messages on this device. 55 | 56 | 57 | 58 | 59 | 60 | Select a sign-in method: 61 | 79 | 80 | 81 | 82 | 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /src/components/SignInGate.tsx: -------------------------------------------------------------------------------- 1 | import React, {useCallback, useContext, useEffect, useState} from "react"; 2 | import Onboarding from "shared/components/Onboarding"; 3 | import Messaging from "shared/components/messaging/master/Messaging"; 4 | import * as Sentry from "@sentry/react"; 5 | import LoginContext from "shared/components/LoginContext"; 6 | import {getAuth, GoogleAuthProvider, onAuthStateChanged, signInWithCredential, signOut} from "firebase/auth"; 7 | import {OAuthTokenResult, useGoogleSignIn} from "shared/util/authUtils"; 8 | import {PeopleContextProvider} from "shared/state/peopleState"; 9 | import {getSecureLS, SecureStorageKey, setSecureLS} from "shared/util/secureStorageUtils"; 10 | import {RemoteLibContext} from "shared/state/remoteLibProvider"; 11 | 12 | enum SignInState { 13 | Waiting, 14 | SignedOut, 15 | SignedIn 16 | } 17 | 18 | export default function SignInGate() { 19 | //Sign-in state 20 | const [state, setState] = useState(SignInState.Waiting); 21 | 22 | /** 23 | * undefined - User is signed out, gAPI has no token 24 | * null - User is signed in, gAPI has no token 25 | * string - User is signed in, gAPI has token 26 | */ 27 | const [accessToken, setAccessToken] = useState(undefined); 28 | const [accessTokenRegistered, setAccessTokenRegistered] = useState(false); 29 | 30 | const signOutAccount = useCallback(() => { 31 | //Sign out of Firebase 32 | signOut(getAuth()); 33 | 34 | //Reset the access token 35 | setAccessToken(undefined); 36 | setSecureLS(SecureStorageKey.GoogleRefreshToken, undefined); 37 | }, [setAccessToken]); 38 | 39 | const handleGoogleSignIn = useCallback(async (result: OAuthTokenResult) => { 40 | //Sign in to Firebase 41 | try { 42 | await signInWithCredential(getAuth(), GoogleAuthProvider.credential(result.id_token)); 43 | } catch(error) { 44 | console.warn("Unable to authenticate Google Sign-In token with Firebase:", error); 45 | return; 46 | } 47 | 48 | //Set the access token 49 | setAccessToken(result.access_token); 50 | setSecureLS(SecureStorageKey.GoogleRefreshToken, result.refresh_token); 51 | }, [setAccessToken]); 52 | 53 | const [isAuthResponseSession, signInWithGoogle, exchangeRefreshToken] = useGoogleSignIn(handleGoogleSignIn); 54 | 55 | //Apply the access token to gAPI 56 | const remoteLibState = useContext(RemoteLibContext); 57 | useEffect(() => { 58 | //Ignore if gAPI isn't loaded 59 | if(!remoteLibState.gapiLoaded) return; 60 | 61 | //Update the gAPI value 62 | if(accessToken) { 63 | gapi.client.setToken({access_token: accessToken}); 64 | } else { 65 | gapi.client.setToken(null); 66 | } 67 | 68 | //If the access token is null, let people utils error out 69 | setAccessTokenRegistered(accessToken !== undefined); 70 | }, [accessToken, setAccessTokenRegistered, remoteLibState.gapiLoaded]); 71 | 72 | useEffect(() => { 73 | return onAuthStateChanged(getAuth(), (user) => { 74 | if(user == null) { 75 | //Update the state 76 | setState(SignInState.SignedOut); 77 | } else { 78 | //Update the state 79 | setState(SignInState.SignedIn); 80 | 81 | //Set the Sentry user 82 | Sentry.setUser({ 83 | id: user.uid, 84 | email: user.email ?? undefined 85 | }); 86 | 87 | //If this sign-in wasn't initiated by a sign-in session, load the token from disk 88 | if(!isAuthResponseSession) { 89 | (async () => { 90 | const refreshToken = await getSecureLS(SecureStorageKey.GoogleRefreshToken); 91 | 92 | //Ignore if we don't have a refresh token in local storage 93 | if(refreshToken === undefined) { 94 | console.warn("User is signed in, but no refresh token is available!"); 95 | setAccessToken(null); 96 | return; 97 | } 98 | 99 | //Get a new access token with the refresh token 100 | let accessToken: string; 101 | try { 102 | const exchangeResult = await exchangeRefreshToken(refreshToken); 103 | accessToken = exchangeResult.access_token; 104 | } catch(error) { 105 | //Invalid token, ask user to reauthenticate 106 | console.warn(`Failed to exchange stored refresh token: ${error}`); 107 | signOutAccount(); 108 | return; 109 | } 110 | 111 | //Set the access token 112 | setAccessToken(accessToken); 113 | })(); 114 | } 115 | } 116 | }); 117 | }, [setState, isAuthResponseSession, exchangeRefreshToken, signOutAccount]); 118 | 119 | let main: React.ReactElement | null; 120 | switch(state) { 121 | case SignInState.Waiting: 122 | main = null; 123 | break; 124 | case SignInState.SignedOut: 125 | main = ( 126 | 127 | ); 128 | break; 129 | case SignInState.SignedIn: 130 | main = ( 131 | 132 | 133 | 134 | ); 135 | break; 136 | } 137 | 138 | return ( 139 | 142 | {main} 143 | 144 | ); 145 | } -------------------------------------------------------------------------------- /src/components/WidthContainer.tsx: -------------------------------------------------------------------------------- 1 | import {Box, styled} from "@mui/material"; 2 | 3 | const WidthContainer = styled(Box)(({theme}) => ({ 4 | width: "100%", 5 | maxWidth: 1000, 6 | paddingLeft: theme.spacing(2), 7 | paddingRight: theme.spacing(2), 8 | margin: "auto", 9 | 10 | flexGrow: 1, 11 | flexShrink: 1, 12 | minHeight: 0 13 | })); 14 | export default WidthContainer; -------------------------------------------------------------------------------- /src/components/calling/CallNotificationIncoming.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Button, Paper, Stack, Typography} from "@mui/material"; 3 | 4 | export default function CallNotificationIncoming(props: { 5 | caller?: string, 6 | onDecline?: VoidFunction, 7 | onAccept?: VoidFunction, 8 | loading?: boolean 9 | }) { 10 | return ( 11 | 16 | 17 | Incoming FaceTime call from {props.caller} 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | } -------------------------------------------------------------------------------- /src/components/calling/CallNotificationOutgoing.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Button, Paper, Stack, Typography} from "@mui/material"; 3 | 4 | export default function CallNotificationOutgoing(props: { 5 | callee?: string, 6 | onCancel?: VoidFunction, 7 | loading?: boolean 8 | }) { 9 | return ( 10 | 15 | 16 | Calling {props.callee} on FaceTime… 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | } -------------------------------------------------------------------------------- /src/components/calling/CallOverlay.tsx: -------------------------------------------------------------------------------- 1 | import React, {useCallback, useContext, useEffect, useMemo, useState} from "react"; 2 | import { 3 | Button, 4 | Dialog, 5 | DialogActions, 6 | DialogContent, 7 | DialogContentText, 8 | DialogTitle, 9 | Stack, 10 | ThemeProvider 11 | } from "@mui/material"; 12 | import {createTheme, useTheme} from "@mui/material/styles"; 13 | import CallNotificationIncoming from "shared/components/calling/CallNotificationIncoming"; 14 | import CallNotificationOutgoing from "shared/components/calling/CallNotificationOutgoing"; 15 | import * as ConnectionManager from "shared/connection/connectionManager"; 16 | import {getMemberTitleSync} from "shared/util/conversationUtils"; 17 | import CallEvent from "shared/data/callEvent"; 18 | import {SnackbarContext} from "shared/components/control/SnackbarProvider"; 19 | import {getNotificationUtils} from "shared/interface/notification/notificationUtils"; 20 | import {PeopleContext} from "shared/state/peopleState"; 21 | 22 | export default function CallOverlay() { 23 | const displaySnackbar = useContext(SnackbarContext); 24 | const existingTheme = useTheme(); 25 | const existingThemeMode = existingTheme.palette.mode; 26 | 27 | //Invert theme 28 | const theme = useMemo(() => { 29 | return createTheme({ 30 | palette: { 31 | mode: existingThemeMode === "light" ? "dark" : "light", 32 | messageIncoming: undefined, 33 | messageOutgoing: undefined, 34 | messageOutgoingTextMessage: undefined 35 | } 36 | }); 37 | }, [existingThemeMode]); 38 | 39 | //Subscribe to incoming caller updates 40 | const [incomingCaller, setIncomingCaller] = useState(undefined); 41 | useEffect(() => { 42 | ConnectionManager.incomingCallerEmitter.subscribe((caller) => { 43 | //Update the caller state 44 | setIncomingCaller(caller); 45 | 46 | //Display a notification 47 | getNotificationUtils().updateCallerNotification(caller); 48 | }); 49 | return () => ConnectionManager.incomingCallerEmitter.unsubscribe(setIncomingCaller); 50 | }, [setIncomingCaller]); 51 | 52 | //Subscribe to outgoing callee updates 53 | const [outgoingCallee, setOutgoingCallee] = useState(undefined); 54 | useEffect(() => { 55 | ConnectionManager.outgoingCalleeEmitter.subscribe(setOutgoingCallee); 56 | return () => ConnectionManager.outgoingCalleeEmitter.unsubscribe(setOutgoingCallee); 57 | }, [setOutgoingCallee]); 58 | 59 | const peopleState = useContext(PeopleContext); 60 | const outgoingCalleeReadable = useMemo(() => { 61 | if(outgoingCallee === undefined) { 62 | return ""; 63 | } else { 64 | return getMemberTitleSync(outgoingCallee, peopleState); 65 | } 66 | }, [outgoingCallee, peopleState]); 67 | 68 | //Set to true between the time that we have responded to an incoming call, and the server has yet to answer our message 69 | const [incomingCallLoading, setIncomingCallLoading] = useState(false); 70 | useEffect(() => { 71 | //When the incoming caller changes, reset the loading state 72 | setIncomingCallLoading(false); 73 | }, [incomingCaller, setIncomingCallLoading]); 74 | 75 | const declineIncomingCall = useCallback(() => { 76 | setIncomingCallLoading(true); 77 | ConnectionManager.handleIncomingFaceTimeCall(incomingCaller!, false); 78 | }, [setIncomingCallLoading, incomingCaller]); 79 | 80 | const acceptIncomingCall = useCallback(() => { 81 | setIncomingCallLoading(true); 82 | ConnectionManager.handleIncomingFaceTimeCall(incomingCaller!, true); 83 | }, [setIncomingCallLoading, incomingCaller]); 84 | 85 | const [outgoingCallLoading, setOutgoingCallLoading] = useState(false); 86 | useEffect(() => { 87 | //When the outgoing callee changes, reset the loading state 88 | setOutgoingCallLoading(false); 89 | }, [outgoingCallee, setOutgoingCallLoading]); 90 | 91 | const cancelOutgoingCall = useCallback(() => { 92 | setOutgoingCallLoading(true); 93 | ConnectionManager.dropFaceTimeCallServer(); 94 | }, [setOutgoingCallLoading]); 95 | 96 | const [errorDetailsDisplay, setErrorDetailsDisplay] = useState(undefined); 97 | 98 | //Subscribe to event updates 99 | useEffect(() => { 100 | const listener = (event: CallEvent) => { 101 | switch(event.type) { 102 | case "outgoingAccepted": 103 | case "incomingHandled": 104 | //Open the FaceTime link in a new tab 105 | window.open(event.faceTimeLink, "_blank"); 106 | break; 107 | case "outgoingError": 108 | case "incomingHandleError": 109 | //Let the user know that something went wrong 110 | displaySnackbar({ 111 | message: "Your call couldn't be completed", 112 | action: ( 113 | 116 | ), 117 | }); 118 | break; 119 | } 120 | }; 121 | 122 | ConnectionManager.callEventEmitter.subscribe(listener); 123 | return () => ConnectionManager.callEventEmitter.unsubscribe(listener); 124 | }, [displaySnackbar, setErrorDetailsDisplay]); 125 | 126 | return (<> 127 | 128 | 134 | {incomingCaller !== undefined && ( 135 | 140 | )} 141 | 142 | {outgoingCallee !== undefined && ( 143 | 147 | )} 148 | 149 | 150 | 151 | setErrorDetailsDisplay(undefined)}> 154 | Call error details 155 | 156 | 157 | {errorDetailsDisplay} 158 | 159 | 160 | 161 | 164 | 165 | 166 | ); 167 | } -------------------------------------------------------------------------------- /src/components/control/AppTheme.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {createTheme, ThemeProvider} from "@mui/material/styles"; 3 | import {CssBaseline, useMediaQuery} from "@mui/material"; 4 | 5 | export default function AppTheme(props: {children: React.ReactNode}) { 6 | const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); 7 | 8 | const theme = React.useMemo(() => createTheme({ 9 | typography: { 10 | fontFamily: [ 11 | "-apple-system", 12 | "BlinkMacSystemFont", 13 | '"Segoe UI"', 14 | "Roboto", 15 | '"Helvetica Neue"', 16 | "Arial", 17 | "sans-serif", 18 | '"Apple Color Emoji"', 19 | '"Segoe UI Emoji"', 20 | '"Segoe UI Symbol"', 21 | ].join(","), 22 | }, 23 | palette: { 24 | mode: prefersDarkMode ? "dark" : "light", 25 | primary: { 26 | main: "#448AFF", 27 | dark: "#366FCC", 28 | light: "#52A7FF", 29 | }, 30 | messageIncoming: prefersDarkMode ? { 31 | main: "#393939", 32 | contrastText: "#FFF" 33 | } : { 34 | main: "#EDEDED", 35 | contrastText: "rgba(0, 0, 0, 0.87)" 36 | }, 37 | messageOutgoing: { 38 | main: "#448AFF", 39 | contrastText: "#FFF", 40 | }, 41 | messageOutgoingTextMessage: { 42 | main: "#2ECC71", 43 | contrastText: "#FFF", 44 | }, 45 | divider: prefersDarkMode ? "rgba(255, 255, 255, 0.1)" : "#EEEEEE", 46 | background: { 47 | default: prefersDarkMode ? "#1E1E1E" : "#FFFFFF", 48 | sidebar: prefersDarkMode ? "#272727" : "#FAFAFA" 49 | } 50 | }, 51 | components: { 52 | MuiCssBaseline: { 53 | styleOverrides: { 54 | "@global": { 55 | html: { 56 | scrollbarColor: prefersDarkMode ? "#303030 #424242" : undefined 57 | } 58 | } 59 | } 60 | } 61 | } 62 | }), [prefersDarkMode]); 63 | 64 | return ( 65 | 66 | 67 | {props.children} 68 | 69 | ); 70 | } 71 | 72 | declare module "@mui/material/styles/createPalette" { 73 | interface Palette { 74 | messageIncoming: Palette["primary"]; 75 | messageOutgoing: Palette["primary"]; 76 | messageOutgoingTextMessage: Palette["primary"]; 77 | } 78 | 79 | interface PaletteOptions { 80 | messageIncoming?: PaletteOptions["primary"]; 81 | messageOutgoing?: PaletteOptions["primary"]; 82 | messageOutgoingTextMessage?: PaletteOptions["primary"]; 83 | } 84 | 85 | interface TypeBackground { 86 | sidebar: string; 87 | } 88 | } -------------------------------------------------------------------------------- /src/components/control/SnackbarProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Snackbar} from "@mui/material"; 3 | import {SnackbarCloseReason} from "@mui/material/Snackbar/Snackbar"; 4 | 5 | interface SnackbarData { 6 | message: string; 7 | action?: React.ReactNode; 8 | } 9 | 10 | interface SnackbarFunction { 11 | (data: SnackbarData): void 12 | } 13 | 14 | export const SnackbarContext = React.createContext(() => console.error("No snackbar function provided")); 15 | 16 | export default function SnackbarProvider(props: {children: React.ReactNode}) { 17 | const [open, setOpen] = React.useState(false); 18 | const [data, setData] = React.useState(); 19 | 20 | function displaySnackbar(data: SnackbarData) { 21 | setOpen(true); 22 | setData(data); 23 | } 24 | 25 | function handleClose(event: React.SyntheticEvent | Event, reason: SnackbarCloseReason) { 26 | if(reason === "clickaway") return; 27 | setOpen(false); 28 | } 29 | 30 | return ( 31 | 32 | 42 | {props.children} 43 | 44 | ); 45 | } -------------------------------------------------------------------------------- /src/components/icon/BorderedCloseIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {SvgIcon, SvgIconProps} from "@mui/material"; 3 | import {useTheme} from "@mui/material/styles"; 4 | 5 | export default function BorderedCloseIcon(props: SvgIconProps) { 6 | const theme = useTheme(); 7 | 8 | const colorBackground = theme.palette.mode === "light" ? theme.palette.grey["700"] : theme.palette.grey["300"]; 9 | const colorSymbol = theme.palette.mode === "light" ? theme.palette.grey["200"] : theme.palette.grey["700"]; 10 | 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | } -------------------------------------------------------------------------------- /src/components/icon/CreateConversationIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import {SvgIcon, SvgIconProps} from "@mui/material"; 4 | 5 | export default function CreateConversationIcon(props: SvgIconProps) { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | ); 13 | } -------------------------------------------------------------------------------- /src/components/icon/DiscordIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import {SvgIcon, SvgIconProps} from "@mui/material"; 4 | 5 | export default function DiscordIcon(props: SvgIconProps) { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/components/icon/MessageCheckIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import {SvgIcon, SvgIconProps} from "@mui/material"; 4 | 5 | export default function MessageCheckIcon(props: SvgIconProps) { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } -------------------------------------------------------------------------------- /src/components/icon/PushIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import {SvgIcon, SvgIconProps} from "@mui/material"; 4 | 5 | export default function PushIcon(props: SvgIconProps) { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } -------------------------------------------------------------------------------- /src/components/icon/RedditIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import {SvgIcon, SvgIconProps} from "@mui/material"; 4 | 5 | export default function RedditIcon(props: SvgIconProps) { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/components/icon/TapbackDislikeIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import {SvgIcon, SvgIconProps} from "@mui/material"; 4 | 5 | export default function TapbackDislikeIcon(props: SvgIconProps) { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } -------------------------------------------------------------------------------- /src/components/icon/TapbackEmphasisIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import {SvgIcon, SvgIconProps} from "@mui/material"; 4 | 5 | export default function TapbackEmphasisIcon(props: SvgIconProps) { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } -------------------------------------------------------------------------------- /src/components/icon/TapbackLaughIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import {SvgIcon, SvgIconProps} from "@mui/material"; 4 | 5 | export default function TapbackLaughIcon(props: SvgIconProps) { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } -------------------------------------------------------------------------------- /src/components/icon/TapbackLikeIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import {SvgIcon, SvgIconProps} from "@mui/material"; 4 | 5 | export default function TapbackLikeIcon(props: SvgIconProps) { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } -------------------------------------------------------------------------------- /src/components/icon/TapbackLoveIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import {SvgIcon, SvgIconProps} from "@mui/material"; 4 | 5 | export default function TapbackLoveIcon(props: SvgIconProps) { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } -------------------------------------------------------------------------------- /src/components/icon/TapbackQuestionIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import {SvgIcon, SvgIconProps} from "@mui/material"; 4 | 5 | export default function TapbackQuestionIcon(props: SvgIconProps) { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } -------------------------------------------------------------------------------- /src/components/logo/AirMessageLogo.module.css: -------------------------------------------------------------------------------- 1 | .logo { 2 | display: flex; 3 | align-items: center; 4 | font-family: 'Manrope', sans-serif; 5 | font-size: 22px; 6 | font-weight: bold; 7 | color: red; 8 | } 9 | 10 | .logo > img { 11 | width: 22px; 12 | height: 22px; 13 | } 14 | 15 | .logo > span { 16 | margin-left: 8px; 17 | } -------------------------------------------------------------------------------- /src/components/logo/AirMessageLogo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styles from "./AirMessageLogo.module.css"; 3 | import {useTheme} from "@mui/material/styles"; 4 | 5 | export default function AirMessageLogo() { 6 | const textColor = useTheme().palette.text.primary; 7 | 8 | return ( 9 |
10 | 11 | AirMessage 12 |
13 | ); 14 | } 15 | 16 | function Logo(props: {color: string}) { 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | } -------------------------------------------------------------------------------- /src/components/messaging/create/DetailCreateAddressButton.tsx: -------------------------------------------------------------------------------- 1 | import {AddressData} from "shared/interface/people/peopleUtils"; 2 | import React, {useCallback} from "react"; 3 | import {Button, ButtonProps, styled} from "@mui/material"; 4 | import MessageCheckIcon from "shared/components/icon/MessageCheckIcon"; 5 | import {ChatBubbleOutline} from "@mui/icons-material"; 6 | 7 | const ToggleButton = styled(Button, { 8 | shouldForwardProp: (prop) => prop !== "amEnabled" 9 | })<{amEnabled: boolean} & ButtonProps>(({amEnabled, theme}) => ({ 10 | color: amEnabled 11 | ? theme.palette.primary.main 12 | : theme.palette.text.primary, 13 | textAlign: "start", 14 | textTransform: "none", 15 | opacity: amEnabled ? 1 : 0.7 16 | })); 17 | 18 | /** 19 | * A checkbox button that represents a selectable address 20 | */ 21 | export default function DetailCreateAddressButton(props: { 22 | address: AddressData, 23 | selected: boolean, 24 | onClick: () => void 25 | }) { 26 | //Build the display value from the address and its label 27 | let display = props.address.displayValue; 28 | if(props.address.label !== undefined) display += ` (${props.address.label})`; 29 | 30 | const onMouseDown = useCallback((event: React.MouseEvent) => { 31 | event.preventDefault(); 32 | }, []); 33 | 34 | return ( 35 | : } 38 | size="small" 39 | onClick={props.onClick} 40 | onMouseDown={onMouseDown}> 41 | {display} 42 | 43 | ); 44 | } -------------------------------------------------------------------------------- /src/components/messaging/create/DetailCreateDirectSendButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {alpha, Avatar, ButtonBase, Typography} from "@mui/material"; 3 | 4 | /** 5 | * A row entry to send a message directly to a specified address 6 | */ 7 | export default function DetailCreateDirectSendButton(props: { 8 | address: string; 9 | onClick: () => void; 10 | }) { 11 | return ( 12 | theme.transitions.create(["background-color", "box-shadow", "border"], { 18 | duration: theme.transitions.duration.short, 19 | }), 20 | borderRadius: 1, 21 | display: "flex", 22 | flexDirection: "row", 23 | justifyContent: "flex-start", 24 | "&:hover": { 25 | backgroundColor: (theme) => alpha(theme.palette.text.primary, theme.palette.action.hoverOpacity), 26 | } 27 | }}> 28 | 32 | Send to {props.address} 33 | 34 | ); 35 | } -------------------------------------------------------------------------------- /src/components/messaging/create/DetailCreateListSubheader.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Typography} from "@mui/material"; 3 | 4 | /** 5 | * A single-character list subheader for people lists 6 | */ 7 | export default function DetailCreateListSubheader(props: {children: React.ReactNode}) { 8 | return ( 9 | 17 | {props.children} 18 | 19 | ); 20 | } -------------------------------------------------------------------------------- /src/components/messaging/create/DetailCreateSelectionChip.tsx: -------------------------------------------------------------------------------- 1 | import NewMessageUser from "shared/data/newMessageUser"; 2 | import React, {useMemo} from "react"; 3 | import {Avatar, Chip, Theme, Tooltip} from "@mui/material"; 4 | import {SxProps} from "@mui/system"; 5 | 6 | /** 7 | * A user selection chip that can be removed 8 | */ 9 | export default function DetailCreateSelectionChip(props: { 10 | sx?: SxProps; 11 | selection: NewMessageUser; 12 | allSelections: NewMessageUser[]; 13 | onRemove?: () => void; 14 | }) { 15 | const {selection, allSelections, onRemove} = props; 16 | 17 | const [ 18 | label, 19 | tooltip 20 | ] = useMemo((): [string, string | undefined] => { 21 | //If the user has no name, use their display address 22 | if(selection.name === undefined) { 23 | return [ 24 | selection.displayAddress, 25 | selection.addressLabel !== undefined 26 | ? `${selection.displayAddress} (${selection.addressLabel})` 27 | : undefined 28 | ]; 29 | } 30 | 31 | //If there is no address label, display the name 32 | if(selection.addressLabel === undefined) { 33 | return [ 34 | selection.name, 35 | selection.displayAddress 36 | ]; 37 | } 38 | 39 | //If there are multiple entries with the same name, append 40 | //the address label as a discriminator 41 | if(allSelections.some((allSelectionsEntry) => 42 | selection !== allSelectionsEntry 43 | && selection.name === allSelectionsEntry.name)) { 44 | return [ 45 | `${selection.name} (${selection.addressLabel})`, 46 | selection.displayAddress 47 | ]; 48 | } 49 | 50 | //Just display the name 51 | return [ 52 | selection.name, 53 | `${selection.displayAddress} (${selection.addressLabel})` 54 | ]; 55 | }, [selection, allSelections]); 56 | 57 | const chip = ( 58 | } 62 | onDelete={onRemove} /> 63 | ); 64 | 65 | if(tooltip !== undefined) { 66 | return ({chip}); 67 | } else { 68 | return chip; 69 | } 70 | } -------------------------------------------------------------------------------- /src/components/messaging/detail/DetailError.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | height: 100%; 3 | 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | } 8 | 9 | .main { 10 | margin: 16px; 11 | max-width: 1000px; 12 | 13 | display: flex; 14 | flex-direction: row; 15 | align-items: flex-start; 16 | justify-content: center; 17 | } 18 | 19 | .split { 20 | display: flex; 21 | flex-direction: column; 22 | 23 | margin-inline-start: 16px; 24 | } 25 | 26 | .icon { 27 | width: 64px !important; 28 | height: 64px !important; 29 | } 30 | 31 | .buttonRow { 32 | display: flex; 33 | flex-direction: row; 34 | 35 | margin-top: 12px; 36 | } 37 | 38 | .buttonRowReverse { 39 | flex-direction: row-reverse; 40 | } 41 | 42 | .buttonRow > button:not(:first-child) { 43 | margin-left: 16px; 44 | } -------------------------------------------------------------------------------- /src/components/messaging/detail/DetailLoading.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Box, LinearProgress, linearProgressClasses, Typography} from "@mui/material"; 3 | 4 | export default function DetailLoading() { 5 | return ( 6 | 13 | 14 | Getting your messages… 15 | 16 | 17 | 25 | 26 | ); 27 | } -------------------------------------------------------------------------------- /src/components/messaging/detail/DetailWelcome.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function DetailWelcome() { 4 | return null; 5 | } -------------------------------------------------------------------------------- /src/components/messaging/dialog/ChangelogDialog.tsx: -------------------------------------------------------------------------------- 1 | import {appVersion, getFormattedBuildDate, releaseHash} from "shared/data/releaseInfo"; 2 | import {Dialog, DialogContent, DialogTitle, Typography} from "@mui/material"; 3 | import Markdown from "shared/components/Markdown"; 4 | import changelog from "shared/resources/text/changelog.md"; 5 | import React from "react"; 6 | 7 | /** 8 | * A dialog that shows the app version and latest changelog 9 | */ 10 | export default function ChangelogDialog(props: {isOpen: boolean, onDismiss: () => void}) { 11 | //Generating the build details 12 | const buildDate = getFormattedBuildDate(); 13 | const buildVersion = `AirMessage for web ${appVersion}`; 14 | const detailedBuildVersion = buildVersion + ` (${releaseHash ?? "unlinked"})`; 15 | const buildTitle = buildVersion + (buildDate ? (`, ${WPEnv.ENVIRONMENT === "production" ? "released" : "built"} ${buildDate}`) : ""); 16 | 17 | return ( 18 | 22 | Release notes 23 | 24 | {buildTitle} 25 | 26 | 27 | 28 | ); 29 | } -------------------------------------------------------------------------------- /src/components/messaging/dialog/FaceTimeLinkDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, {useCallback} from "react"; 2 | import {Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle} from "@mui/material"; 3 | 4 | export default function FaceTimeLinkDialog(props: { 5 | isOpen: boolean, 6 | onDismiss: () => void, 7 | link: string 8 | }) { 9 | const propsLink = props.link; 10 | const propsOnDismiss = props.onDismiss; 11 | const copyLink = useCallback(async () => { 12 | await navigator.clipboard.writeText(propsLink); 13 | propsOnDismiss(); 14 | }, [propsLink, propsOnDismiss]); 15 | 16 | return ( 17 | 20 | FaceTime link 21 | 22 | 23 | {props.link} 24 | 25 | 26 | 27 | 30 | 33 | 34 | 35 | ); 36 | } -------------------------------------------------------------------------------- /src/components/messaging/dialog/FeedbackDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, {useCallback} from "react"; 2 | import {getPlatformUtils} from "shared/interface/platform/platformUtils"; 3 | import {appVersion} from "shared/data/releaseInfo"; 4 | import { 5 | getActiveCommVer, 6 | getActiveProxyType, 7 | getServerSoftwareVersion, 8 | getServerSystemVersion, 9 | targetCommVerString 10 | } from "shared/connection/connectionManager"; 11 | import {discordAddress, redditAddress, supportEmail} from "shared/data/linkConstants"; 12 | import {Button, Dialog, DialogContent, DialogContentText, DialogTitle, Stack, ThemeProvider, createTheme} from "@mui/material"; 13 | import {Mail} from "@mui/icons-material"; 14 | import DiscordIcon from "shared/components/icon/DiscordIcon"; 15 | import RedditIcon from "shared/components/icon/RedditIcon"; 16 | 17 | const discordTheme = createTheme({ palette: { primary: { main: "#5865F2" } } }); 18 | const redditTheme = createTheme({ palette: { primary: { main: "#FF5700" } } }); 19 | 20 | /** 21 | * A dialog that presents help and feedback options 22 | */ 23 | export default function FeedbackDialog(props: { 24 | isOpen: boolean, 25 | onDismiss: () => void 26 | }) { 27 | const onClickEmail = useCallback(async () => { 28 | const body = 29 | `\n\n---------- DEVICE INFORMATION ----------` + 30 | Object.entries(await getPlatformUtils().getExtraEmailDetails()) 31 | .map(([key, value]) => `\n${key}: ${value}`) 32 | .join("") + 33 | `\nUser agent: ${navigator.userAgent}` + 34 | `\nClient version: ${appVersion}` + 35 | `\nCommunications version: ${getActiveCommVer()?.join(".")} (target ${targetCommVerString})` + 36 | `\nProxy type: ${getActiveProxyType()}` + 37 | `\nServer system version: ${getServerSystemVersion()}` + 38 | `\nServer software version: ${getServerSoftwareVersion()}`; 39 | const url = `mailto:${supportEmail}?subject=${encodeURIComponent("AirMessage feedback")}&body=${encodeURIComponent(body)}`; 40 | window.open(url, "_blank"); 41 | }, []); 42 | 43 | const onClickDiscord = useCallback(() => { 44 | window.open(discordAddress, "_blank"); 45 | }, []); 46 | 47 | const onClickReddit = useCallback(() => { 48 | window.open(redditAddress, "_blank"); 49 | }, []); 50 | 51 | return ( 52 | 55 | Help and feedback 56 | 57 | 58 | Have a bug to report, a feature to suggest, or anything else to say? Contact us or discuss with others using the links below. 59 | 60 | 61 | 65 | 70 | 71 | 72 | 78 | 79 | 80 | 81 | 87 | 88 | 89 | 90 | 91 | ); 92 | } -------------------------------------------------------------------------------- /src/components/messaging/dialog/RemoteUpdateDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, {useCallback, useEffect, useMemo, useRef, useState} from "react"; 2 | import { 3 | Alert, 4 | AlertTitle, 5 | Box, 6 | Button, 7 | Dialog, 8 | DialogContent, 9 | DialogTitle, 10 | LinearProgress, 11 | linearProgressClasses, 12 | Stack, 13 | Typography 14 | } from "@mui/material"; 15 | import Markdown from "shared/components/Markdown"; 16 | import ServerUpdateData from "shared/data/serverUpdateData"; 17 | import * as ConnectionManager from "../../../connection/connectionManager"; 18 | import {compareVersions} from "shared/util/versionUtils"; 19 | import {ConnectionErrorCode, RemoteUpdateErrorCode} from "shared/data/stateCodes"; 20 | import {ConnectionListener, RemoteUpdateListener} from "../../../connection/connectionManager"; 21 | import {remoteUpdateErrorCodeToDisplay} from "shared/util/languageUtils"; 22 | 23 | /** 24 | * A dialog that allows the user to update their server remotely 25 | */ 26 | export default function RemoteUpdateDialog(props: { 27 | isOpen: boolean, 28 | onDismiss: () => void, 29 | update: ServerUpdateData, 30 | }) { 31 | const [isInstalling, setInstalling] = useState(false); 32 | const installTimeout = useRef(undefined); 33 | const [errorDetails, setErrorDetails] = useState<{message: string, details?: string} | undefined>(undefined); 34 | 35 | const remoteInstallable = props.update.remoteInstallable; 36 | 37 | //Check if this server update introduces a newer server protocol than we support 38 | const protocolCompatible = useMemo((): boolean => { 39 | return compareVersions(ConnectionManager.targetCommVer, props.update.protocolRequirement) >= 0; 40 | }, [props.update.protocolRequirement]); 41 | 42 | const updateNotice = useMemo((): string => { 43 | if(!remoteInstallable) { 44 | return `This server update cannot be installed remotely. 45 | Please check AirMessage Server on ${ConnectionManager.getServerComputerName()} for details.`; 46 | } else if(!protocolCompatible) { 47 | return `This server update requires a newer version of AirMessage for web than is currently running. 48 | Please refresh the webpage to check for updates.`; 49 | } else { 50 | return `This will install the latest version of AirMessage Server on ${ConnectionManager.getServerComputerName()}. 51 | You will lose access to messaging functionality while the update installs. 52 | In case the installation fails, please make sure you have desktop access to this computer before installing.`; 53 | } 54 | }, [remoteInstallable, protocolCompatible]); 55 | 56 | //Installs a remote update 57 | const installUpdate = useCallback(() => { 58 | //Install the update 59 | setInstalling(true); 60 | setErrorDetails(undefined); 61 | ConnectionManager.installRemoteUpdate(props.update.id); 62 | 63 | //Start the installation timeout 64 | installTimeout.current = setTimeout(() => { 65 | installTimeout.current = undefined; 66 | 67 | //Show an error snackbar 68 | setErrorDetails({message: remoteUpdateErrorCodeToDisplay(RemoteUpdateErrorCode.Timeout)}); 69 | }, 10 * 1000); 70 | }, [setInstalling, setErrorDetails, props.update.id]); 71 | 72 | //Register for update events 73 | const propsOnDismiss = props.onDismiss; 74 | useEffect(() => { 75 | const connectionListener: ConnectionListener = { 76 | onClose(reason: ConnectionErrorCode): void { 77 | //Close the dialog 78 | propsOnDismiss(); 79 | }, 80 | onConnecting(): void {}, 81 | onOpen(): void {} 82 | }; 83 | ConnectionManager.addConnectionListener(connectionListener); 84 | 85 | const updateListener: RemoteUpdateListener = { 86 | onInitiate(): void { 87 | //Cancel the timeout 88 | if(installTimeout.current !== undefined) { 89 | clearTimeout(installTimeout.current); 90 | installTimeout.current = undefined; 91 | } 92 | }, 93 | 94 | onError(code: RemoteUpdateErrorCode, details?: string): void { 95 | //Set the update as not installing 96 | setInstalling(false); 97 | 98 | //Show an error snackbar 99 | setErrorDetails({message: remoteUpdateErrorCodeToDisplay(code), details}); 100 | }, 101 | }; 102 | ConnectionManager.addRemoteUpdateListener(updateListener); 103 | 104 | return () => { 105 | ConnectionManager.removeConnectionListener(connectionListener); 106 | ConnectionManager.removeRemoteUpdateListener(updateListener); 107 | }; 108 | }, [propsOnDismiss, setInstalling, setErrorDetails]); 109 | 110 | return ( 111 | 115 | Server update 116 | 117 | 118 | AirMessage Server {props.update.version} is now available - you have {ConnectionManager.getServerSoftwareVersion()} 119 | 120 | 121 | 122 | 123 | 124 | 125 | {updateNotice} 126 | 127 | 128 | {!isInstalling ? (<> 129 | {remoteInstallable && ( 130 | protocolCompatible ? ( 131 | 137 | ) : ( 138 | 144 | ) 145 | )} 146 | ) : (<> 147 | 148 | Installing update… 149 | 157 | 158 | )} 159 | 160 | {errorDetails !== undefined && ( 161 | 162 | Failed to install update 163 | {errorDetails.message} 164 | 165 | )} 166 | 167 | 168 | 169 | ); 170 | } -------------------------------------------------------------------------------- /src/components/messaging/dialog/SignOutDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, {useCallback, useContext} from "react"; 2 | import LoginContext from "shared/components/LoginContext"; 3 | import {Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle} from "@mui/material"; 4 | 5 | /** 6 | * A dialog that prompts the user to sign out 7 | */ 8 | export default function SignOutDialog(props: {isOpen: boolean, onDismiss: VoidFunction}) { 9 | const onDismiss = props.onDismiss; 10 | const signOut = useContext(LoginContext).signOut; 11 | const onConfirm = useCallback(() => { 12 | //Dismissing the dialog 13 | onDismiss(); 14 | 15 | //Signing out 16 | signOut(); 17 | }, [onDismiss, signOut]); 18 | 19 | return ( 20 | 23 | Sign out of AirMessage? 24 | 25 | 26 | You won't be able to send or receive any messages from this computer 27 | 28 | 29 | 30 | 33 | 36 | 37 | 38 | ); 39 | } -------------------------------------------------------------------------------- /src/components/messaging/dialog/UpdateRequiredDialog.tsx: -------------------------------------------------------------------------------- 1 | import {Dialog, DialogContent, DialogContentText, DialogTitle, Link, Typography} from "@mui/material"; 2 | import React from "react"; 3 | 4 | /** 5 | * A dialog that warns the user to check their server for updates 6 | */ 7 | export default function UpdateRequiredDialog(props: {isOpen: boolean, onDismiss: () => void}) { 8 | return ( 9 | 13 | Your server needs to be updated 14 | 15 | 16 | 17 | You're running an unsupported version of AirMessage Server. 18 | 19 | 20 | 21 | Unsupported versions of AirMessage Server may contain security or stability issues, 22 | and will start refusing connections late January. 23 | 24 | 25 | 26 | Please install the latest version of AirMessage Server from airmessage.org on your Mac. 27 | 28 | 29 | 30 | 31 | ); 32 | } -------------------------------------------------------------------------------- /src/components/messaging/master/ConnectionBanner.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {WifiOffRounded} from "@mui/icons-material"; 3 | import {ConnectionErrorCode} from "../../../data/stateCodes"; 4 | import {errorCodeToShortDisplay} from "shared/util/languageUtils"; 5 | import SidebarBanner from "shared/components/messaging/master/SidebarBanner"; 6 | 7 | /** 8 | * A sidebar banner that informs the user about 9 | * a connection error 10 | */ 11 | export default function ConnectionBanner(props: {error: ConnectionErrorCode}) { 12 | const errorDisplay = errorCodeToShortDisplay(props.error); 13 | 14 | return ( 15 | } 17 | message={errorDisplay.message} 18 | button={errorDisplay.button?.label} 19 | onClickButton={errorDisplay.button?.onClick} /> 20 | ); 21 | } -------------------------------------------------------------------------------- /src/components/messaging/master/DetailFrame.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Divider, IconButton, Stack, Toolbar, Typography} from "@mui/material"; 3 | import {VideocamOutlined} from "@mui/icons-material"; 4 | 5 | interface Props { 6 | title: string; 7 | children: React.ReactNode; 8 | className?: string; 9 | 10 | showCall?: boolean; 11 | onClickCall?: () => void; 12 | } 13 | 14 | /** 15 | * A frame component with a toolbar, used to wrap detail views 16 | */ 17 | export const DetailFrame = React.forwardRef((props, ref) => { 18 | return ( 19 | 20 | 21 | 27 | {props.title} 28 | 29 | 30 | {props.showCall && ( 31 | 34 | 35 | 36 | )} 37 | 38 | 39 | 40 | 41 | {props.children} 42 | 43 | ); 44 | }); -------------------------------------------------------------------------------- /src/components/messaging/master/GroupAvatar.module.css: -------------------------------------------------------------------------------- 1 | .avatarContainer { 2 | width: 40px; 3 | height: 40px; 4 | position: relative; 5 | } 6 | 7 | .avatar { 8 | position: absolute !important; 9 | } 10 | 11 | .avatar2 { 12 | composes: avatar; 13 | 14 | width: 55% !important; 15 | height: 55% !important; 16 | } 17 | 18 | .avatar2first { 19 | composes: avatar2; 20 | 21 | top: 0 !important; 22 | left: 0 !important; 23 | } 24 | 25 | .avatar2second { 26 | composes: avatar2; 27 | 28 | bottom: 0 !important; 29 | right: 0 !important; 30 | } 31 | 32 | .avatar3 { 33 | composes: avatar; 34 | 35 | width: 46% !important; 36 | height: 46% !important; 37 | } 38 | 39 | .avatar3first { 40 | composes: avatar3; 41 | 42 | top: 0 !important; 43 | left: 0 !important; 44 | right: 0 !important; 45 | margin-left: auto !important; 46 | margin-right: auto !important; 47 | } 48 | 49 | .avatar3second { 50 | composes: avatar3; 51 | 52 | bottom: 0 !important; 53 | left: 0 !important; 54 | } 55 | 56 | .avatar3third { 57 | composes: avatar3; 58 | 59 | bottom: 0 !important; 60 | right: 0 !important; 61 | } 62 | 63 | .avatar4 { 64 | composes: avatar; 65 | 66 | width: 46% !important; 67 | height: 46% !important; 68 | } 69 | 70 | .avatar4first { 71 | composes: avatar4; 72 | 73 | top: 0 !important; 74 | left: 0 !important; 75 | } 76 | 77 | .avatar4second { 78 | composes: avatar4; 79 | 80 | top: 0 !important; 81 | right: 0 !important; 82 | } 83 | 84 | .avatar4third { 85 | composes: avatar4; 86 | 87 | bottom: 0 !important; 88 | left: 0 !important; 89 | } 90 | 91 | .avatar4fourth { 92 | composes: avatar4; 93 | 94 | bottom: 0 !important; 95 | right: 0 !important; 96 | } -------------------------------------------------------------------------------- /src/components/messaging/master/GroupAvatar.tsx: -------------------------------------------------------------------------------- 1 | import React, {useContext, useMemo} from "react"; 2 | import styles from "./GroupAvatar.module.css"; 3 | 4 | import {Avatar, Box} from "@mui/material"; 5 | import {PersonData} from "../../../interface/people/peopleUtils"; 6 | import {colorFromContact} from "../../../util/avatarUtils"; 7 | import {SxProps} from "@mui/system"; 8 | import {Theme} from "@mui/material/styles"; 9 | import {PeopleContext} from "shared/state/peopleState"; 10 | 11 | export default function GroupAvatar(props: {members: string[]}) { 12 | const members = props.members; 13 | const peopleState = useContext(PeopleContext); 14 | const personArray = useMemo( 15 | () => members.map((address) => peopleState.getPerson(address)), 16 | [members, peopleState] 17 | ); 18 | 19 | let body: React.ReactNode[]; 20 | switch(members.length) { 21 | case 0: 22 | body = [ 23 | 24 | ]; 25 | break; 26 | case 1: 27 | body = [ 28 | 29 | ]; 30 | break; 31 | case 2: 32 | body = [ 33 | , 34 | 35 | ]; 36 | break; 37 | case 3: 38 | body = [ 39 | , 40 | , 41 | 42 | ]; 43 | break; 44 | case 4: 45 | default: 46 | body = [ 47 | , 48 | , 49 | , 50 | 51 | ]; 52 | } 53 | 54 | return ( 55 | 56 | {body} 57 | 58 | ); 59 | } 60 | 61 | function PersonAvatar(props: { 62 | person?: PersonData, 63 | sx?: SxProps, 64 | style?: React.CSSProperties, 65 | className?: string 66 | }) { 67 | return ; 73 | } -------------------------------------------------------------------------------- /src/components/messaging/master/ListConversation.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from "react"; 2 | 3 | import * as ConversationUtils from "../../../util/conversationUtils"; 4 | import {isConversationPreviewMessage} from "../../../util/conversationUtils"; 5 | 6 | import {ListItemAvatar, ListItemButton, ListItemText, Typography, TypographyProps} from "@mui/material"; 7 | 8 | import {Conversation, ConversationPreview} from "../../../data/blocks"; 9 | import {appleSendStyleBubbleInvisibleInk} from "../../../data/appleConstants"; 10 | import {getLastUpdateStatusTime} from "../../../util/dateUtils"; 11 | import GroupAvatar from "./GroupAvatar"; 12 | import {ConversationPreviewType} from "../../../data/stateCodes"; 13 | import {useConversationTitle} from "shared/util/hookUtils"; 14 | 15 | export default function ListConversation(props: { 16 | conversation: Conversation; 17 | selected?: boolean; 18 | highlighted?: boolean; 19 | onSelected: () => void; 20 | }) { 21 | //Getting the conversation title 22 | const title = useConversationTitle(props.conversation); 23 | 24 | const primaryStyle: TypographyProps = props.highlighted ? { 25 | color: "primary", 26 | sx: { 27 | fontSize: "1rem", 28 | fontWeight: "bold" 29 | } 30 | } : { 31 | sx: { 32 | fontSize: "1rem", 33 | fontWeight: 500 34 | } 35 | }; 36 | 37 | const secondaryStyle: TypographyProps = props.highlighted ? { 38 | color: "textPrimary", 39 | sx: { 40 | fontWeight: "bold" 41 | } 42 | } : {}; 43 | 44 | return ( 45 | 62 | 63 | 64 | 65 | 77 | 86 | {getLastUpdateStatusTime(props.conversation.preview.date)} 87 | 88 | 89 | ); 90 | } 91 | 92 | function previewString(preview: ConversationPreview): string { 93 | if(isConversationPreviewMessage(preview)) { 94 | if(preview.sendStyle === appleSendStyleBubbleInvisibleInk) return "Message sent with Invisible Ink"; 95 | else if(preview.text) return preview.text; 96 | else if(preview.attachments.length) { 97 | if(preview.attachments.length === 1) { 98 | return ConversationUtils.mimeTypeToPreview(preview.attachments[0]); 99 | } else { 100 | return `${preview.attachments.length} attachments`; 101 | } 102 | } 103 | } else if(preview.type === ConversationPreviewType.ChatCreation) { 104 | return "New conversation created"; 105 | } 106 | 107 | return "Unknown"; 108 | } -------------------------------------------------------------------------------- /src/components/messaging/master/Sidebar.module.css: -------------------------------------------------------------------------------- 1 | .sidebarList { 2 | flex-grow: 1; 3 | overflow-x: hidden; 4 | overflow-y: scroll; 5 | } 6 | 7 | .sidebarListLoading { 8 | flex-grow: 1; 9 | overflow: hidden; 10 | } -------------------------------------------------------------------------------- /src/components/messaging/master/SidebarBanner.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Box, Button, Paper, Stack, Typography} from "@mui/material"; 3 | 4 | /** 5 | * A banner element that has an icon, a message, 6 | * and an optional button 7 | */ 8 | export default function SidebarBanner(props: { 9 | icon: React.ReactNode; 10 | message: string; 11 | button?: string; 12 | onClickButton?: VoidFunction; 13 | secondaryButton?: string; 14 | onClickSecondaryButton?: VoidFunction; 15 | }) { 16 | return ( 17 | 27 | 28 | {props.icon} 29 | 30 | 31 | 35 | {props.message} 36 | 37 | {props.secondaryButton !== undefined && ( 38 | 43 | )} 44 | 45 | {props.button !== undefined && ( 46 | 52 | )} 53 | 54 | 55 | 56 | ); 57 | } -------------------------------------------------------------------------------- /src/components/messaging/thread/MessageInput.tsx: -------------------------------------------------------------------------------- 1 | import React, {ChangeEvent, useCallback} from "react"; 2 | import {Box, IconButton, InputBase, Stack} from "@mui/material"; 3 | import PushIcon from "../../icon/PushIcon"; 4 | import {QueuedFile} from "../../../data/blocks"; 5 | import {QueuedAttachmentImage} from "./queue/QueuedAttachmentImage"; 6 | import QueuedAttachmentGeneric from "./queue/QueuedAttachmentGeneric"; 7 | import {QueuedAttachmentProps} from "./queue/QueuedAttachment"; 8 | 9 | interface Props { 10 | placeholder: string; 11 | message: string; 12 | attachments: QueuedFile[]; 13 | onMessageChange: (value: string) => void; 14 | onMessageSubmit: (message: string, attachments: QueuedFile[]) => void; 15 | onAttachmentAdd: (files: File[]) => void; 16 | onAttachmentRemove: (value: QueuedFile) => void; 17 | } 18 | 19 | export default function MessageInput(props: Props) { 20 | const { 21 | onMessageChange: propsOnMessageChange, 22 | onMessageSubmit: propsOnMessageSubmit, 23 | message: propsMessage, 24 | attachments: propsAttachments, 25 | onAttachmentAdd: propsOnAttachmentAdd 26 | } = props; 27 | 28 | const handleChange = useCallback((event: ChangeEvent) => { 29 | propsOnMessageChange(event.target.value); 30 | }, [propsOnMessageChange]); 31 | 32 | const submitInput = useCallback(() => { 33 | propsOnMessageSubmit(propsMessage, propsAttachments); 34 | }, [propsOnMessageSubmit, propsMessage, propsAttachments]); 35 | 36 | const handleKeyDown = useCallback((event: React.KeyboardEvent) => { 37 | if(!event.shiftKey && event.key === "Enter") { 38 | event.preventDefault(); 39 | submitInput(); 40 | } 41 | }, [submitInput]); 42 | 43 | const handlePaste = useCallback((event: React.ClipboardEvent) => { 44 | propsOnAttachmentAdd(Array.from(event.clipboardData.files)); 45 | }, [propsOnAttachmentAdd]); 46 | 47 | return ( 48 | 55 | {props.attachments.length > 0 && 56 | 70 | {props.attachments.map((file) => { 71 | const queueData: QueuedAttachmentProps = { 72 | file: file.file, 73 | onRemove: () => props.onAttachmentRemove(file) 74 | }; 75 | 76 | let component: React.ReactNode; 77 | if(file.file.type.startsWith("image/")) { 78 | component = (); 79 | } else { 80 | component = (); 81 | } 82 | 83 | return component; 84 | })} 85 | 86 | } 87 | 88 | 89 | 104 | 115 | 116 | 117 | 118 | 119 | ); 120 | } -------------------------------------------------------------------------------- /src/components/messaging/thread/item/ConversationActionLine.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Typography} from "@mui/material"; 3 | 4 | export default function ConversationActionLine(props: {children?: React.ReactNode}) { 5 | return ( 6 | 13 | {props.children} 14 | 15 | ); 16 | } -------------------------------------------------------------------------------- /src/components/messaging/thread/item/ConversationActionParticipant.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {ParticipantAction} from "../../../../data/blocks"; 3 | import {ParticipantActionType} from "../../../../data/stateCodes"; 4 | import ConversationActionLine from "./ConversationActionLine"; 5 | import {usePersonName} from "shared/util/hookUtils"; 6 | 7 | export default function ConversationActionParticipant(props: {action: ParticipantAction}) { 8 | const userName = usePersonName(props.action.user); 9 | const targetName = usePersonName(props.action.target); 10 | 11 | return ( 12 | 13 | {generateMessage(props.action.type, userName, targetName)} 14 | 15 | ); 16 | } 17 | 18 | function generateMessage(type: ParticipantActionType, user: string | undefined, target: string | undefined): string { 19 | if(type === ParticipantActionType.Join) { 20 | if(user === target) { 21 | if(user) return `${user} joined the conversation`; 22 | else return `You joined the conversation`; 23 | } else { 24 | if(user && target) return `${user} added ${target} to the conversation`; 25 | else if(user) return `${user} added you to the conversation`; 26 | else if(target) return `You added ${target} to the conversation`; 27 | } 28 | } else if(type === ParticipantActionType.Leave) { 29 | if(user === target) { 30 | if(user) return `${user} left the conversation`; 31 | else return `You left the conversation`; 32 | } else { 33 | if(user && target) return `${user} removed ${target} from the conversation`; 34 | else if(user) return `${user} removed you from the conversation`; 35 | else if(target) return `You removed ${target} from the conversation`; 36 | } 37 | } 38 | 39 | return "Unknown event"; 40 | } -------------------------------------------------------------------------------- /src/components/messaging/thread/item/ConversationActionRename.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {ChatRenameAction} from "../../../../data/blocks"; 3 | import ConversationActionLine from "./ConversationActionLine"; 4 | import {usePersonName} from "shared/util/hookUtils"; 5 | 6 | export default function ConversationActionRename(props: {action: ChatRenameAction}) { 7 | const userName = usePersonName(props.action.user); 8 | 9 | return ( 10 | 11 | {generateMessage(userName, props.action.chatName)} 12 | 13 | ); 14 | } 15 | 16 | function generateMessage(user: string | undefined, title: string | undefined): string { 17 | if(user) { 18 | if(title) return `${user} named the conversation "${title}"`; 19 | else return `${user} removed the conversation name`; 20 | } else { 21 | if(title) return `You named the conversation "${title}"`; 22 | else return `You removed the conversation name`; 23 | } 24 | } -------------------------------------------------------------------------------- /src/components/messaging/thread/item/bubble/MessageBubbleImage.tsx: -------------------------------------------------------------------------------- 1 | import React, {useCallback, useState} from "react"; 2 | import MessageBubbleWrapper from "shared/components/messaging/thread/item/bubble/MessageBubbleWrapper"; 3 | import {StickerItem, TapbackItem} from "shared/data/blocks"; 4 | import {Backdrop, Box, ButtonBase, IconButton, styled, Toolbar, Tooltip, Typography} from "@mui/material"; 5 | import {getFlowBorderRadius, MessagePartFlow} from "shared/util/messageFlow"; 6 | import {useBlobURL} from "shared/util/hookUtils"; 7 | import {createTheme, Theme, ThemeProvider} from "@mui/material/styles"; 8 | import {downloadURL} from "shared/util/browserUtils"; 9 | import {ArrowBack, SaveAlt} from "@mui/icons-material"; 10 | 11 | const ImagePreview = styled("img")(({theme}) => ({ 12 | backgroundColor: theme.palette.background.sidebar, 13 | maxWidth: "100%", 14 | })); 15 | 16 | const lightboxTheme = createTheme({ 17 | palette: { 18 | mode: "dark", 19 | messageIncoming: undefined, 20 | messageOutgoing: undefined, 21 | messageOutgoingTextMessage: undefined 22 | } 23 | }); 24 | 25 | /** 26 | * A message bubble that displays an image thumbnail, 27 | * and allows the user to enlarge the image by 28 | * clicking on it 29 | */ 30 | export default function MessageBubbleImage(props: { 31 | flow: MessagePartFlow; 32 | data: ArrayBuffer | Blob; 33 | name: string; 34 | type: string; 35 | stickers: StickerItem[]; 36 | tapbacks: TapbackItem[]; 37 | }) { 38 | const imageURL = useBlobURL(props.data); 39 | const [previewOpen, setPreviewOpen] = useState(false); 40 | 41 | /** 42 | * Saves the attachment file to the user's downloads 43 | */ 44 | const downloadAttachmentFile = useCallback((event: React.MouseEvent) => { 45 | //So that we don't dismiss the backdrop 46 | event.stopPropagation(); 47 | 48 | if(imageURL === undefined) return; 49 | downloadURL(imageURL, props.type, props.name); 50 | }, [imageURL, props.type, props.name]); 51 | 52 | const borderRadius = getFlowBorderRadius(props.flow); 53 | 54 | return (<> 55 | 56 | theme.zIndex.modal, 59 | flexDirection: "column", 60 | alignItems: "stretch", 61 | backgroundColor: "rgba(0, 0, 0, 0.9)" 62 | }} 63 | open={previewOpen} 64 | onClick={() => setPreviewOpen(false)}> 65 | 66 | 67 | 68 | 69 | 70 | 74 | {props.name} 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 89 | 98 | 99 | 100 | 101 | 102 | 107 | setPreviewOpen(true)}> 110 | 114 | 115 | 116 | ); 117 | } -------------------------------------------------------------------------------- /src/components/messaging/thread/item/bubble/MessageBubbleText.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Linkify from "linkify-react"; 3 | import MessageBubbleWrapper from "shared/components/messaging/thread/item/bubble/MessageBubbleWrapper"; 4 | import {StickerItem, TapbackItem} from "shared/data/blocks"; 5 | import {styled, Typography} from "@mui/material"; 6 | import {getFlowBorderRadius, MessagePartFlow} from "shared/util/messageFlow"; 7 | 8 | const MessageBubbleTypography = styled(Typography)(({theme}) => ({ 9 | paddingLeft: theme.spacing(1.5), 10 | paddingRight: theme.spacing(1.5), 11 | paddingTop: theme.spacing(0.75), 12 | paddingBottom: theme.spacing(0.75), 13 | overflowWrap: "break-word", 14 | wordBreak: "break-word", 15 | hyphens: "auto", 16 | whiteSpace: "break-spaces", 17 | 18 | "& a": { 19 | color: "inherit" 20 | } 21 | })); 22 | 23 | /** 24 | * A message bubble that displays text content 25 | */ 26 | export default function MessageBubbleText(props: { 27 | flow: MessagePartFlow; 28 | text: string; 29 | stickers: StickerItem[]; 30 | tapbacks: TapbackItem[]; 31 | }) { 32 | return ( 33 | 38 | 43 | {props.text} 44 | 45 | 46 | ); 47 | } -------------------------------------------------------------------------------- /src/components/messaging/thread/item/bubble/MessageBubbleWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, {CSSProperties, useEffect, useState} from "react"; 2 | import {StickerItem, TapbackItem} from "shared/data/blocks"; 3 | import {Box, BoxProps, Fade, styled} from "@mui/material"; 4 | import TapbackRow from "shared/components/messaging/thread/item/bubble/TapbackRow"; 5 | import StickerStack from "./StickerStack"; 6 | import {getFlowOpacity, MessagePartFlow} from "shared/util/messageFlow"; 7 | 8 | const BoxWrapper = styled(Box, { 9 | shouldForwardProp: (prop) => prop !== "amTapbackPadding" 10 | })<{amTapbackPadding: boolean} & BoxProps>(({amTapbackPadding, theme}) => ({ 11 | position: "relative", 12 | marginBottom: theme.spacing(amTapbackPadding ? 1.25 : 0) 13 | })); 14 | 15 | /** 16 | * A wrapper around a bubble component that 17 | * layers tapbacks and stickers 18 | */ 19 | export default function MessageBubbleWrapper(props: { 20 | flow: MessagePartFlow; 21 | stickers: StickerItem[]; 22 | tapbacks: TapbackItem[]; 23 | maxWidth?: number | string; 24 | children?: React.ReactNode; 25 | }) { 26 | const [isPeeking, setPeeking] = useState(false); 27 | 28 | return ( 29 | 0} 33 | onMouseEnter={() => setPeeking(true)} 34 | onMouseLeave={() => setPeeking(false)}> 35 | {props.children} 36 | 37 | {/* Stickers */} 38 | {props.stickers.length > 0 && ( 39 | 40 | )} 41 | 42 | {/* Tapback row */} 43 | {props.tapbacks.length > 0 && ( 44 | 45 | )} 46 | 47 | ); 48 | } -------------------------------------------------------------------------------- /src/components/messaging/thread/item/bubble/StickerStack.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {StickerItem} from "shared/data/blocks"; 3 | import {Box, BoxProps, styled} from "@mui/material"; 4 | import {useBlobURL} from "shared/util/hookUtils"; 5 | 6 | const BoxStackContainer = styled(Box, { 7 | shouldForwardProp: (prop) => prop !== "peek" 8 | })<{peek: boolean} & BoxProps>(({peek, theme}) => ({ 9 | zIndex: 2, 10 | position: "absolute", 11 | left: "50%", 12 | top: "50%", 13 | transform: "translate(-50%, -50%)", 14 | pointerEvents: "none", 15 | 16 | transition: theme.transitions.create(["opacity"]), 17 | opacity: peek ? 0.05 : 1 18 | })); 19 | const BoxStackEntry = styled("img")({ 20 | position: "absolute", 21 | left: "50%", 22 | top: "50%", 23 | transform: "translate(-50%, -50%)", 24 | maxWidth: 100, 25 | maxHeight: 100 26 | }); 27 | 28 | /** 29 | * A stack of stickers to be overlayed on 30 | * top of a message bubble 31 | * @constructor 32 | */ 33 | 34 | export default function StickerStack(props: { 35 | stickers: StickerItem[]; 36 | peek?: boolean; 37 | }) { 38 | return ( 39 | 40 | {props.stickers.map((sticker, index) => ( 41 | 42 | ))} 43 | 44 | ); 45 | } 46 | 47 | function StickerStackEntry(props: {sticker: StickerItem}) { 48 | const imageURL = useBlobURL(props.sticker.data, props.sticker.dataType); 49 | 50 | return ( 51 | 52 | ); 53 | } -------------------------------------------------------------------------------- /src/components/messaging/thread/item/bubble/TapbackChip.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {TapbackType} from "shared/data/stateCodes"; 3 | import TapbackLoveIcon from "shared/components/icon/TapbackLoveIcon"; 4 | import TapbackLikeIcon from "shared/components/icon/TapbackLikeIcon"; 5 | import TapbackDislikeIcon from "shared/components/icon/TapbackDislikeIcon"; 6 | import TapbackLaughIcon from "shared/components/icon/TapbackLaughIcon"; 7 | import TapbackEmphasisIcon from "shared/components/icon/TapbackEmphasisIcon"; 8 | import TapbackQuestionIcon from "shared/components/icon/TapbackQuestionIcon"; 9 | import {Stack, Typography} from "@mui/material"; 10 | import {Theme} from "@mui/material/styles"; 11 | 12 | /** 13 | * A single tapback chip 14 | * @param props.type The type of tapback 15 | * @param props.count The amount of reactions of this tapback type 16 | */ 17 | export default function TapbackChip(props: { 18 | type: TapbackType; 19 | count: number; 20 | }) { 21 | let Icon: React.ElementType; 22 | switch(props.type) { 23 | case TapbackType.Love: 24 | Icon = TapbackLoveIcon; 25 | break; 26 | case TapbackType.Like: 27 | Icon = TapbackLikeIcon; 28 | break; 29 | case TapbackType.Dislike: 30 | Icon = TapbackDislikeIcon; 31 | break; 32 | case TapbackType.Laugh: 33 | Icon = TapbackLaughIcon; 34 | break; 35 | case TapbackType.Emphasis: 36 | Icon = TapbackEmphasisIcon; 37 | break; 38 | case TapbackType.Question: 39 | Icon = TapbackQuestionIcon; 40 | break; 41 | } 42 | 43 | return ( 44 | 58 | theme.palette.text.secondary, 61 | width: 12, 62 | height: 12 63 | }} /> 64 | 65 | {props.count > 1 && ( 66 | 67 | {props.count} 68 | 69 | )} 70 | 71 | ); 72 | } -------------------------------------------------------------------------------- /src/components/messaging/thread/item/bubble/TapbackRow.tsx: -------------------------------------------------------------------------------- 1 | import React, {useMemo} from "react"; 2 | import {TapbackItem} from "shared/data/blocks"; 3 | import {TapbackType} from "shared/data/stateCodes"; 4 | import {Stack} from "@mui/material"; 5 | import TapbackChip from "shared/components/messaging/thread/item/bubble/TapbackChip"; 6 | 7 | /** 8 | * A row of tapback chips, to be attached to the bottom 9 | * of a message bubble 10 | */ 11 | export default function TapbackRow(props: { 12 | tapbacks: TapbackItem[] 13 | }) { 14 | //Counting tapbacks 15 | const tapbackCounts = useMemo(() => 16 | props.tapbacks.reduce>((accumulator, item) => { 17 | const key = item.tapbackType; 18 | accumulator.set(key, (accumulator.get(key) ?? 0) + 1); 19 | return accumulator; 20 | }, new Map()) 21 | , [props.tapbacks]); 22 | 23 | return ( 24 | 33 | {Array.from(tapbackCounts.entries()).map(([tapbackType, count]) => ( 34 | 35 | ))} 36 | 37 | ); 38 | } -------------------------------------------------------------------------------- /src/components/messaging/thread/queue/QueuedAttachment.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import BorderedCloseIcon from "../../../icon/BorderedCloseIcon"; 3 | import {Box, IconButton, Tooltip} from "@mui/material"; 4 | 5 | export interface QueuedAttachmentProps { 6 | file: File; 7 | onRemove: () => void; 8 | } 9 | 10 | export default function QueuedAttachment(props: {children: React.ReactNode, queueData: QueuedAttachmentProps}) { 11 | return ( 12 | 13 | 18 | {props.children} 19 | 20 | 26 | 27 | 28 | 29 | 30 | ); 31 | } -------------------------------------------------------------------------------- /src/components/messaging/thread/queue/QueuedAttachmentGeneric.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {InsertDriveFileRounded} from "@mui/icons-material"; 3 | import QueuedAttachment, {QueuedAttachmentProps} from "./QueuedAttachment"; 4 | import {Box} from "@mui/material"; 5 | 6 | export default function QueuedAttachmentGeneric(props: {queueData: QueuedAttachmentProps}) { 7 | return ( 8 | 9 | 20 | 21 | 22 | 23 | ); 24 | } -------------------------------------------------------------------------------- /src/components/messaging/thread/queue/QueuedAttachmentImage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import QueuedAttachment, {QueuedAttachmentProps} from "./QueuedAttachment"; 3 | import {useBlobURL} from "shared/util/hookUtils"; 4 | import {styled} from "@mui/material"; 5 | 6 | const AttachmentImage = styled("img")(({theme}) => ({ 7 | width: "100%", 8 | height: "100%", 9 | objectFit: "cover", 10 | borderRadius: theme.shape.borderRadius 11 | })); 12 | 13 | export function QueuedAttachmentImage(props: {queueData: QueuedAttachmentProps}) { 14 | const imageURL = useBlobURL(props.queueData.file, props.queueData.file.type); 15 | 16 | return ( 17 | 18 | 19 | 20 | ); 21 | } -------------------------------------------------------------------------------- /src/components/skeleton/ConversationSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Skeleton, Stack} from "@mui/material"; 3 | 4 | /** 5 | * A placeholder conversation entry 6 | */ 7 | export default function ConversationSkeleton() { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | } -------------------------------------------------------------------------------- /src/connection/comm5/airPacker.ts: -------------------------------------------------------------------------------- 1 | import ByteBuffer from "bytebuffer"; 2 | 3 | const bufferSize = 4 * 1024 * 1024; //4 MiB 4 | 5 | type Payload = ArrayBuffer | Uint8Array; 6 | 7 | const textEncoder = new TextEncoder(); 8 | 9 | export default class AirPacker { 10 | private static readonly instance = new AirPacker(ByteBuffer.allocate(bufferSize)); 11 | 12 | public static get(): AirPacker { 13 | return this.instance; 14 | } 15 | 16 | public static initialize(bufferSize: number) { 17 | return new AirPacker(ByteBuffer.allocate(bufferSize)); 18 | } 19 | 20 | private constructor(private readonly buffer: ByteBuffer) { 21 | 22 | } 23 | 24 | packBoolean(value: boolean) { 25 | this.buffer.writeByte(value ? 1 : 0); 26 | } 27 | 28 | packShort(value: number) { 29 | this.buffer.writeShort(value); 30 | } 31 | 32 | packInt(value: number) { 33 | this.buffer.writeInt(value); 34 | } 35 | //packInt alias 36 | packArrayHeader: (value: number) => void = this.packInt; 37 | 38 | packLong(value: number) { 39 | this.buffer.writeLong(value); 40 | } 41 | 42 | packDouble(value: number) { 43 | this.buffer.writeDouble(value); 44 | } 45 | 46 | packString(value: string) { 47 | this.packPayload(textEncoder.encode(value)); 48 | } 49 | 50 | packNullableString(value: string | null) { 51 | if(value) { 52 | this.packBoolean(true); 53 | this.packString(value); 54 | } else { 55 | this.packBoolean(false); 56 | } 57 | } 58 | 59 | packStringArray(value: string[]) { 60 | this.packArrayHeader(value.length); 61 | for(const entry of value) this.packString(entry); 62 | } 63 | 64 | packPayload(value: Payload) { 65 | this.packInt(value.byteLength); 66 | this.buffer.append(value); 67 | } 68 | 69 | packNullablePayload(value: Payload | null) { 70 | if(value) { 71 | this.packBoolean(true); 72 | this.packPayload(value); 73 | } else { 74 | this.packBoolean(false); 75 | } 76 | } 77 | 78 | toArrayBuffer(): ArrayBuffer { 79 | return this.buffer.buffer.slice(0, this.buffer.offset); 80 | } 81 | 82 | reset() { 83 | this.buffer.reset(); 84 | } 85 | } -------------------------------------------------------------------------------- /src/connection/comm5/airUnpacker.ts: -------------------------------------------------------------------------------- 1 | import ByteBuffer from "bytebuffer"; 2 | 3 | const textDecoder = new TextDecoder(); 4 | 5 | export default class AirUnpacker { 6 | private readonly buffer: ByteBuffer; 7 | 8 | constructor(data: ArrayBuffer) { 9 | this.buffer = ByteBuffer.wrap(data); 10 | } 11 | 12 | unpackBoolean(): boolean { 13 | return this.buffer.readByte() === 1; 14 | } 15 | 16 | unpackShort(): number { 17 | return this.buffer.readShort(); 18 | } 19 | 20 | unpackInt(): number { 21 | return this.buffer.readInt(); 22 | } 23 | //unpackInt alias 24 | unpackArrayHeader: () => number = this.unpackInt; 25 | 26 | //https://stackoverflow.com/questions/14200071/javascript-read-8-bytes-to-64-bit-integer 27 | //https://stackoverflow.com/questions/14002205/read-int64-from-node-js-buffer-with-precision-loss 28 | unpackLong(): number { 29 | const high = this.buffer.readUint32(); 30 | const low = this.buffer.readUint32(); 31 | 32 | let val: number = high * 4294967296 + low; 33 | if(val < 0) val += 4294967296; 34 | return val; 35 | } 36 | 37 | unpackDouble(): number { 38 | return this.buffer.readDouble(); 39 | } 40 | 41 | unpackString(): string { 42 | return textDecoder.decode(this.unpackPayload()); 43 | } 44 | 45 | unpackNullableString(): string | undefined { 46 | if(this.unpackBoolean()) { 47 | return this.unpackString(); 48 | } else { 49 | return undefined; 50 | } 51 | } 52 | 53 | unpackStringArray(): string[] { 54 | const length = this.unpackArrayHeader(); 55 | const value: string[] = []; 56 | for(let i = 0; i < length; i++) value[i] = this.unpackString(); 57 | return value; 58 | } 59 | 60 | unpackPayload(): ArrayBuffer { 61 | const length = this.unpackInt(); 62 | return this.buffer.readBytes(length).toArrayBuffer(); 63 | } 64 | 65 | unpackNullablePayload(): ArrayBuffer | undefined { 66 | if(this.unpackBoolean()) { 67 | return this.unpackPayload(); 68 | } else { 69 | return undefined; 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /src/connection/comm5/protocolManager.ts: -------------------------------------------------------------------------------- 1 | import AirUnpacker from "./airUnpacker"; 2 | import ClientComm5 from "./clientComm5"; 3 | import DataProxy from "../dataProxy"; 4 | import ConversationTarget from "shared/data/conversationTarget"; 5 | 6 | export default abstract class ProtocolManager { 7 | constructor(protected communicationsManager: ClientComm5, protected dataProxy: DataProxy) { 8 | 9 | } 10 | 11 | /** 12 | * Handles incoming data received from the server 13 | * 14 | * @param data the data received from the network 15 | * @param wasEncrypted whether this data was encrypted when it was received 16 | */ 17 | public abstract processData(data: ArrayBuffer, wasEncrypted: boolean): void; 18 | 19 | /** 20 | * Sends a ping packet to the server 21 | * 22 | * @return whether or not the message was successfully sent 23 | */ 24 | public abstract sendPing(): boolean; 25 | 26 | /** 27 | * Sends an authentication request to the server 28 | * 29 | * @param unpacker The unpacker of the server's info data, after reading the communications versions 30 | * @return whether or not the message was successfully sent 31 | */ 32 | public abstract sendAuthenticationRequest(unpacker: AirUnpacker): Promise; 33 | 34 | /** 35 | * Requests a message to be sent to the specified conversation 36 | * 37 | * @param requestID the ID of the request 38 | * @param conversation the target conversation 39 | * @param message the message to send 40 | * @return whether or not the request was successfully sent 41 | */ 42 | public abstract sendMessage(requestID: number, conversation: ConversationTarget, message: string): boolean; 43 | 44 | /** 45 | * Sends an attachment file to the specified conversation 46 | * 47 | * @param requestID the ID of the request 48 | * @param conversation the target conversation 49 | * @param file the file to send 50 | * @param progressCallback a callback called periodically with the number of bytes uploaded 51 | * @return a promise that completes with the file hash once the file has been fully uploaded 52 | */ 53 | public abstract sendFile(requestID: number, conversation: ConversationTarget, file: File, progressCallback: (bytesUploaded: number) => void): Promise; 54 | 55 | /** 56 | * Requests the download of a remote attachment 57 | * 58 | * @param requestID the ID of the request 59 | * @param attachmentGUID the GUID of the attachment to fetch 60 | * @return whether or not the request was successful 61 | */ 62 | public abstract requestAttachmentDownload(requestID: number, attachmentGUID: string): boolean; 63 | 64 | /** 65 | * Sends a request to retrieve a list of conversations to present to the user 66 | * 67 | * @return whether or not the action was successful 68 | */ 69 | public abstract requestLiteConversation(): boolean; 70 | 71 | /** 72 | * Requests information regarding a certain list of conversations 73 | * 74 | * @param chatGUIDs a list of chat GUIDs to request information of 75 | * @return whether or not the request was successfully sent 76 | */ 77 | public abstract requestConversationInfo(chatGUIDs: string[]): boolean; 78 | 79 | /** 80 | * Sends a request to retrieve messages from a conversation thread 81 | * 82 | * @param chatGUID the GUID of the chat to fetch messages from 83 | * * @param firstMessageID The ID of the first received message 84 | * @return whether or not the action was successful 85 | */ 86 | public abstract requestLiteThread(chatGUID: string, firstMessageID?: number): boolean; 87 | 88 | /** 89 | * Requests a time range-based message retrieval 90 | * 91 | * @param timeLower the lower time range limit 92 | * @param timeUpper the upper time range limit 93 | * @return whether or not the request was successfully sent 94 | */ 95 | public abstract requestRetrievalTime(timeLower: Date, timeUpper: Date): boolean; 96 | 97 | /** 98 | * Requests an ID range-based message retrieval 99 | * @param idLower The ID to retrieve messages beyond (exclusive) 100 | * @param timeLower The lower time range limit 101 | * @param timeUpper The upper time range limit 102 | * @return Whether the request was successfully sent 103 | */ 104 | public abstract requestRetrievalID(idLower: number, timeLower: Date, timeUpper: Date): boolean; 105 | 106 | /** 107 | * Requests a mass message retrieval 108 | * 109 | * @param requestID the ID used to validate conflicting requests 110 | * @param params the mass retrieval parameters to use 111 | * @return whether or not the request was successfully sent 112 | */ 113 | //public abstract requestRetrievalAll(requestID: number, params: any): boolean; 114 | 115 | /** 116 | * Requests the creation of a new conversation on the server 117 | * @param requestID the ID used to validate conflicting requests 118 | * @param members the participating members' contact addresses for this conversation 119 | * @param service the service that this conversation will use 120 | * @return whether or not the request was successfully sent 121 | */ 122 | public abstract requestChatCreation(requestID: number, members: string[], service: string): boolean; 123 | 124 | /** 125 | * Requests the installation of a remote update 126 | * @param updateID The ID of the update to install 127 | * @return Whether the request was successfully sent 128 | */ 129 | public abstract requestInstallRemoteUpdate(updateID: number): boolean; 130 | 131 | /** 132 | * Requests a new FaceTime link 133 | * @return Whether the request was successfully sent 134 | */ 135 | public abstract requestFaceTimeLink(): boolean; 136 | 137 | /** 138 | * Initiates a new outgoing FaceTime call with the specified addresses 139 | * @param addresses The list of addresses to initiate the call with 140 | * @return Whether the request was successfully sent 141 | */ 142 | public abstract initiateFaceTimeCall(addresses: string[]): boolean; 143 | 144 | /** 145 | * Accepts or rejects a pending incoming FaceTime call 146 | * @param caller The name of the caller to accept or reject the call of 147 | * @param accept True to accept the call, or false to reject 148 | * @return Whether the request was successfully sent 149 | */ 150 | public abstract handleIncomingFaceTimeCall(caller: string, accept: boolean): boolean; 151 | 152 | /** 153 | * Tells the server to leave the FaceTime call. 154 | * This should be called after the client has connected to the call with the 155 | * FaceTime link to avoid two of the same user connected. 156 | * @return Whether the request was successfully sent 157 | */ 158 | public abstract dropFaceTimeCallServer(): boolean; 159 | } -------------------------------------------------------------------------------- /src/connection/connect/nht.ts: -------------------------------------------------------------------------------- 1 | //AirMessage Connect communications version 2 | export const commVer = 1; 3 | 4 | //Shared het header types 5 | /* 6 | * The connected device has been connected successfully 7 | */ 8 | export const nhtConnectionOK = 0; 9 | 10 | //Client-only net header types 11 | 12 | /* 13 | * Proxy the message to the server (client -> connect) 14 | * 15 | * payload - data 16 | */ 17 | export const nhtClientProxy = 100; 18 | 19 | /* 20 | * Add an item to the list of FCM tokens (client -> connect) 21 | * 22 | * string - registration token 23 | */ 24 | export const nhtClientAddFCMToken = 110; 25 | 26 | /* 27 | * Remove an item from the list of FCM tokens (client -> connect) 28 | * 29 | * string - registration token 30 | */ 31 | export const nhtClientRemoveFCMToken = 111; 32 | 33 | //Server-only net header types 34 | 35 | /* 36 | * Notify a new client connection (connect -> server) 37 | * 38 | * - connection ID 39 | */ 40 | export const nhtServerOpen = 200; 41 | 42 | /* 43 | * Close a connected client (server -> connect) 44 | * Notify a closed connection (connect -> server) 45 | * 46 | * - connection ID 47 | */ 48 | export const nhtServerClose = 201; 49 | 50 | /* 51 | * Proxy the message to the client (server -> connect) 52 | * Receive data from a connected client (connect -> server) 53 | * 54 | * - connection ID 55 | * payload - data 56 | */ 57 | export const nhtServerProxy = 210; 58 | 59 | /* 60 | * Proxy the message to all connected clients (server -> connect) 61 | * 62 | * payload - data 63 | */ 64 | export const nhtServerProxyBroadcast = 211; 65 | 66 | /** 67 | * Notify offline clients of a new message 68 | */ 69 | export const nhtServerNotifyPush = 212; 70 | 71 | //Disconnection codes 72 | export const closeCodeIncompatibleProtocol = 4000; //No protocol version matching the one requested 73 | export const closeCodeNoGroup = 4001; //There is no active group with a matching ID 74 | export const closeCodeNoCapacity = 4002; //The client's group is at capacity 75 | export const closeCodeAccountValidation = 4003; //This account couldn't be validated 76 | export const closeCodeServerTokenRefresh = 4004; //The server's provided installation ID is out of date; log in again to re-link this device 77 | export const closeCodeNoActivation = 4005; //This user's account is not activated 78 | export const closeCodeOtherLocation = 4006; //Logged in from another location -------------------------------------------------------------------------------- /src/connection/connect/webSocketCloseEventCodes.ts: -------------------------------------------------------------------------------- 1 | // Defines constants for all websocket close event codes that are used in practice 2 | // Link: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent 3 | 4 | /** 5 | * Normal closure; the connection successfully completed whatever purpose for which it was created. 6 | */ 7 | export const NORMAL_CLOSURE = 1000; 8 | 9 | /** 10 | * The endpoint is going away, either because of a server failure 11 | * or because the browser is navigating away from the page that opened the connection. 12 | */ 13 | export const GOING_AWAY = 1001; 14 | 15 | /** 16 | * The endpoint is terminating the connection due to a protocol error. 17 | */ 18 | export const PROTOCOL_ERROR = 1002; 19 | 20 | /** 21 | * The connection is being terminated because the endpoint received 22 | * data of a type it cannot accept (for example, a text-only endpoint received binary data). 23 | */ 24 | export const UNSUPPORTED_DATA = 1003; 25 | 26 | /** 27 | * Reserved. 28 | * Indicates that no status code was provided even though one was expected. 29 | */ 30 | export const NO_STATUS_RECEIVED = 1005; 31 | 32 | /** 33 | * Reserved. 34 | * Used to indicate that a connection was closed abnormally 35 | * (that is, with no close frame being sent) when a status code is expected. 36 | */ 37 | export const ABNORMAL_CLOSURE = 1006; 38 | 39 | /** 40 | * The endpoint is terminating the connection because a message was received 41 | * that contained inconsistent data (e.g., non-UTF-8 data within a text message). 42 | */ 43 | export const INVALID_FRAME_PAYLOAD = 1007; 44 | 45 | /** 46 | * The endpoint is terminating the connection because it received a message that 47 | * violates its policy. This is a generic status code, used when codes 1003 and 1009 48 | * are not suitable. 49 | */ 50 | export const POLICY_VIOLATION = 1008; 51 | 52 | /** 53 | * The endpoint is terminating the connection because a data frame was 54 | * received that is too large. 55 | */ 56 | export const MESSAGE_TOO_BIG = 1009; 57 | 58 | /** 59 | * The client is terminating the connection because it expected 60 | * the server to negotiate one or more extension, but the server didn't. 61 | */ 62 | export const MISSING_EXTENSION = 1010; 63 | 64 | /** 65 | * The server is terminating the connection because it encountered an unexpected 66 | * condition that prevented it from fulfilling the request. 67 | */ 68 | export const INTERNAL_SERVER_ERROR = 1011; 69 | 70 | /** 71 | * The server is terminating the connection because it is restarting. 72 | */ 73 | export const SERVICE_RESTART = 1012; 74 | 75 | /** 76 | * The server is terminating the connection due to a temporary condition, 77 | * e.g. it is overloaded and is casting off some of its clients. 78 | */ 79 | export const TRY_AGAIN_LATER = 1013; 80 | 81 | /** 82 | * The server was acting as a gateway or proxy and received an invalid response 83 | * from the upstream server. This is similar to 502 HTTP Status Code. 84 | */ 85 | export const BAD_GATEWAY = 1014; 86 | 87 | /** 88 | * Reserved. Indicates that the connection was closed due to a failure to 89 | * perform a TLS handshake (e.g., the server certificate can't be verified). 90 | */ 91 | export const BAD_TLS_HANDSHAKE = 1015; -------------------------------------------------------------------------------- /src/connection/dataProxy.ts: -------------------------------------------------------------------------------- 1 | import {ConnectionErrorCode} from "../data/stateCodes"; 2 | 3 | export type DataProxyListener = { 4 | onOpen: () => void, 5 | onClose: (reason: ConnectionErrorCode) => void, 6 | onMessage: (data: ArrayBuffer, isEncrypted: boolean) => void, 7 | }; 8 | 9 | export default abstract class DataProxy { 10 | abstract readonly proxyType: string; 11 | public listener?: DataProxyListener; 12 | private pendingErrorCode: ConnectionErrorCode | undefined = undefined; 13 | public serverRequestsEncryption: boolean = false; 14 | 15 | /** 16 | * Start this proxy's connection to the server 17 | */ 18 | abstract start(): void; 19 | 20 | /** 21 | * Cancel this proxy's connection to the server 22 | */ 23 | abstract stop(): void; 24 | 25 | /** 26 | * Send a packet to the server 27 | * @param data The packet to send 28 | * @param encrypt Whether to encrypt the data before sending 29 | * @return TRUE if the packet was successfully queued 30 | */ 31 | abstract send(data: ArrayBuffer, encrypt: boolean): void; 32 | 33 | stopWithReason(reason: ConnectionErrorCode) { 34 | this.pendingErrorCode = reason; 35 | this.stop(); 36 | } 37 | 38 | protected notifyOpen() { 39 | this.listener?.onOpen(); 40 | } 41 | 42 | protected notifyClose(reason: ConnectionErrorCode) { 43 | //Consuming the pending error code if it is available 44 | if(this.pendingErrorCode) { 45 | this.listener?.onClose(this.pendingErrorCode); 46 | this.pendingErrorCode = undefined; 47 | } else { 48 | this.listener?.onClose(reason); 49 | } 50 | } 51 | 52 | protected notifyMessage(data: ArrayBuffer, isEncrypted: boolean) { 53 | this.listener?.onMessage(data, isEncrypted); 54 | } 55 | } -------------------------------------------------------------------------------- /src/connection/transferAccumulator.ts: -------------------------------------------------------------------------------- 1 | import pako from "pako"; 2 | 3 | export abstract class TransferAccumulator { 4 | public abstract push(data: ArrayBuffer): void; 5 | public abstract get data(): Blob; 6 | public abstract get length(): number; 7 | } 8 | 9 | export class BasicAccumulator extends TransferAccumulator { 10 | private readonly accumulatedData: ArrayBuffer[] = []; 11 | private dataLength = 0; 12 | 13 | push(data: ArrayBuffer) { 14 | //Adding the data to the array 15 | this.accumulatedData.push(data); 16 | this.dataLength += data.byteLength; 17 | } 18 | 19 | get data() { 20 | return new Blob(this.accumulatedData); 21 | } 22 | 23 | get length() { 24 | return this.dataLength; 25 | } 26 | } 27 | 28 | export class InflatorAccumulator extends TransferAccumulator { 29 | private readonly inflator = new pako.Inflate(); 30 | private accumulatedDataOffset: number = 0; 31 | 32 | push(data: ArrayBuffer) { 33 | //Adding the data to the array 34 | this.inflator.push(data); 35 | this.accumulatedDataOffset += data.byteLength; 36 | } 37 | 38 | get data() { 39 | if(this.inflator.err) throw this.inflator.err; 40 | return new Blob([this.inflator.result as Uint8Array]); 41 | } 42 | 43 | get length() { 44 | return this.accumulatedDataOffset; 45 | } 46 | } -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const contactsScope = "https://www.googleapis.com/auth/contacts.readonly"; 2 | export const googleScope = `openid ${contactsScope}`; 3 | -------------------------------------------------------------------------------- /src/data/appleConstants.ts: -------------------------------------------------------------------------------- 1 | export const appleSendStyleBubbleSlam = "com.apple.MobileSMS.expressivesend.impact"; 2 | export const appleSendStyleBubbleLoud = "com.apple.MobileSMS.expressivesend.loud"; 3 | export const appleSendStyleBubbleGentle = "com.apple.MobileSMS.expressivesend.gentle"; 4 | export const appleSendStyleBubbleInvisibleInk = "com.apple.MobileSMS.expressivesend.invisibleink"; 5 | export const appleSendStyleScrnEcho = "com.apple.messages.effect.CKEchoEffect"; 6 | export const appleSendStyleScrnSpotlight = "com.apple.messages.effect.CKSpotlightEffect"; 7 | export const appleSendStyleScrnBalloons = "com.apple.messages.effect.CKHappyBirthdayEffect"; 8 | export const appleSendStyleScrnConfetti = "com.apple.messages.effect.CKConfettiEffect"; 9 | export const appleSendStyleScrnLove = "com.apple.messages.effect.CKHeartEffect"; 10 | export const appleSendStyleScrnLasers = "com.apple.messages.effect.CKLasersEffect"; 11 | export const appleSendStyleScrnFireworks = "com.apple.messages.effect.CKFireworksEffect"; 12 | export const appleSendStyleScrnShootingStar = "com.apple.messages.effect.CKShootingStarEffect"; 13 | export const appleSendStyleScrnCelebration = "com.apple.messages.effect.CKSparklesEffect"; 14 | 15 | export const appleServiceAppleMessage = "iMessage"; 16 | export const appleServiceTextMessageForwarding = "SMS"; -------------------------------------------------------------------------------- /src/data/blocks.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ParticipantActionType, 3 | MessageError, 4 | MessageStatusCode, 5 | ConversationItemType, 6 | ConversationPreviewType, MessageModifierType, TapbackType 7 | } from "./stateCodes"; 8 | 9 | interface BaseConversation { 10 | localID: LocalConversationID; 11 | service: string; 12 | name?: string; 13 | members: string[]; 14 | preview: ConversationPreview; 15 | unreadMessages: boolean; 16 | //Whether this conversation was created locally, 17 | //and needs to be matched with a conversation on the server 18 | localOnly: boolean; 19 | } 20 | 21 | export interface LinkedConversation extends BaseConversation { 22 | guid: RemoteConversationID; 23 | localOnly: false; 24 | } 25 | 26 | export interface LocalConversation extends BaseConversation { 27 | localOnly: true; 28 | } 29 | 30 | export type Conversation = LinkedConversation | LocalConversation; 31 | 32 | interface ConversationPreviewBase { 33 | readonly type: ConversationPreviewType; 34 | readonly date: Date; 35 | } 36 | 37 | export interface ConversationPreviewMessage extends ConversationPreviewBase { 38 | readonly type: ConversationPreviewType.Message; 39 | readonly text?: string; 40 | readonly sendStyle?: string; 41 | readonly attachments: string[]; 42 | } 43 | 44 | export interface ConversationPreviewChatCreation extends ConversationPreviewBase { 45 | readonly type: ConversationPreviewType.ChatCreation; 46 | } 47 | 48 | export type ConversationPreview = ConversationPreviewMessage | ConversationPreviewChatCreation; 49 | 50 | export interface ConversationItemBase { 51 | readonly itemType: ConversationItemType; 52 | readonly localID?: number; 53 | readonly serverID?: number; 54 | readonly guid?: string; 55 | readonly chatGuid?: RemoteConversationID; 56 | readonly chatLocalID?: LocalConversationID; 57 | readonly date: Date; 58 | } 59 | 60 | export interface MessageItem extends ConversationItemBase { 61 | readonly itemType: ConversationItemType.Message; 62 | readonly text?: string; 63 | readonly subject?: string; 64 | readonly sender?: string; 65 | readonly attachments: AttachmentItem[]; 66 | readonly stickers: StickerItem[]; 67 | readonly tapbacks: TapbackItem[]; 68 | readonly sendStyle?: string; 69 | status: MessageStatusCode; 70 | statusDate?: Date; 71 | error?: MessageError; 72 | progress?: number; //Undefined for hide, -1 for indeterminate, 0-100 for determinate 73 | } 74 | 75 | export interface AttachmentItem { 76 | readonly localID?: number; 77 | readonly guid?: string; 78 | readonly name: string; 79 | readonly type: string; 80 | readonly size: number; 81 | checksum?: string; 82 | data?: File; 83 | } 84 | 85 | export interface MessageModifier { 86 | readonly type: MessageModifierType; 87 | readonly messageGuid: string; 88 | } 89 | 90 | export interface StatusUpdate extends MessageModifier { 91 | status: MessageStatusCode; 92 | date?: Date; 93 | } 94 | 95 | export interface ResponseMessageModifier extends MessageModifier { 96 | readonly messageIndex: number; 97 | readonly sender: string; 98 | } 99 | 100 | export interface StickerItem extends ResponseMessageModifier { 101 | readonly date: Date; 102 | readonly dataType: string; 103 | readonly data: ArrayBuffer; 104 | } 105 | 106 | export interface TapbackItem extends ResponseMessageModifier { 107 | readonly isAddition: boolean; 108 | readonly tapbackType: TapbackType; 109 | } 110 | 111 | export interface ParticipantAction extends ConversationItemBase { 112 | readonly itemType: ConversationItemType.ParticipantAction; 113 | readonly type: ParticipantActionType; 114 | readonly user?: string; 115 | readonly target?: string; 116 | } 117 | 118 | export interface ChatRenameAction extends ConversationItemBase { 119 | readonly itemType: ConversationItemType.ChatRenameAction; 120 | readonly user: string; 121 | readonly chatName: string; 122 | } 123 | 124 | export type ConversationItem = MessageItem | ParticipantAction | ChatRenameAction; 125 | 126 | export interface QueuedFile { 127 | id: number; 128 | file: File; 129 | } 130 | 131 | export type RemoteConversationID = string; 132 | export type LocalConversationID = number; 133 | export type MixedConversationID = RemoteConversationID | LocalConversationID; 134 | 135 | /** 136 | * Gets a {@link MixedConversationID} from a conversation 137 | */ 138 | export function getConversationMixedID(conversation: Conversation): MixedConversationID { 139 | if(conversation.localOnly) { 140 | return conversation.localID; 141 | } else { 142 | return conversation.guid; 143 | } 144 | } 145 | 146 | /** 147 | * Gets a {@link MixedConversationID} from a conversation item, 148 | * or undefined if the item has none 149 | */ 150 | export function getConversationItemMixedID(item: ConversationItem): MixedConversationID | undefined { 151 | return item.chatGuid ?? item.chatLocalID; 152 | } 153 | 154 | export function isLocalConversationID(id: MixedConversationID | undefined): id is LocalConversationID { 155 | return typeof id === "number"; 156 | } 157 | 158 | export function isRemoteConversationID(id: MixedConversationID | undefined): id is RemoteConversationID { 159 | return typeof id === "string"; 160 | } -------------------------------------------------------------------------------- /src/data/callEvent.ts: -------------------------------------------------------------------------------- 1 | type CallEvent = { 2 | type: "outgoingAccepted", 3 | faceTimeLink: string 4 | } | { 5 | type: "outgoingRejected" 6 | } | { 7 | type: "outgoingError", 8 | errorDetails: string | undefined 9 | } | { 10 | type: "incomingHandled", 11 | faceTimeLink: string 12 | } | { 13 | type: "incomingHandleError", 14 | errorDetails: string | undefined 15 | }; 16 | export default CallEvent; -------------------------------------------------------------------------------- /src/data/conversationTarget.ts: -------------------------------------------------------------------------------- 1 | type ConversationTarget = 2 | {type: "linked", guid: string} | 3 | {type: "unlinked", members: string[], service: string}; 4 | 5 | export default ConversationTarget; -------------------------------------------------------------------------------- /src/data/fileDownloadResult.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents downloaded file data 3 | */ 4 | export default interface FileDownloadResult { 5 | data: Blob, //The data of the downloaded file 6 | downloadName?: string, //The updated name of the downloaded file 7 | downloadType?: string //The updated MIME type of the downloaded file 8 | } 9 | 10 | /** 11 | * Represents merged attachment file data 12 | */ 13 | export interface FileDisplayResult { 14 | data: Blob | undefined, //The data of the downloaded file 15 | name: string, //The display name of the downloaded file 16 | type: string //The display MIME type of the downloaded file 17 | } -------------------------------------------------------------------------------- /src/data/linkConstants.ts: -------------------------------------------------------------------------------- 1 | export const supportEmail = "hello@airmessage.org"; 2 | export const discordAddress = "https://discord.gg/prjdNWTzfA"; 3 | export const redditAddress = "https://reddit.com/r/AirMessage"; 4 | -------------------------------------------------------------------------------- /src/data/newMessageUser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An interface that holds data for a user 3 | * to send a new message to 4 | */ 5 | export default interface NewMessageUser { 6 | //The user's display name 7 | name?: string; 8 | //The user's avatar image 9 | avatar?: string; 10 | //The normalized address 11 | address: string; 12 | //The display address 13 | displayAddress: string; 14 | //The address label 15 | addressLabel?: string; 16 | } -------------------------------------------------------------------------------- /src/data/paletteSpecifier.ts: -------------------------------------------------------------------------------- 1 | import {Palette, PaletteColor} from "@mui/material"; 2 | 3 | /** 4 | * A color resolveable by MUI's palette 5 | */ 6 | type PaletteSpecifier = `${keyof Palette}.${keyof PaletteColor}`; 7 | export default PaletteSpecifier; 8 | 9 | export function accessPaletteColor(palette: Palette, specifier: PaletteSpecifier): string { 10 | const specifierSplit = specifier.split(".", 2) as [keyof Palette, keyof PaletteColor]; 11 | const color = palette[specifierSplit[0]] as PaletteColor; 12 | return color[specifierSplit[1]]; 13 | } -------------------------------------------------------------------------------- /src/data/releaseInfo.ts: -------------------------------------------------------------------------------- 1 | import {DateTime} from "luxon"; 2 | 3 | export const appVersion = WPEnv.PACKAGE_VERSION; 4 | export const releaseHash = WPEnv.RELEASE_HASH; 5 | export const buildDate = WPEnv.BUILD_DATE; 6 | 7 | export function getFormattedBuildDate(): string | undefined { 8 | if(!buildDate) return undefined; 9 | return DateTime.fromMillis(buildDate).toLocaleString(DateTime.DATE_FULL); 10 | } -------------------------------------------------------------------------------- /src/data/serverUpdateData.ts: -------------------------------------------------------------------------------- 1 | export default interface ServerUpdateData { 2 | id: number, 3 | protocolRequirement: number[], 4 | version: string, 5 | notes: string, 6 | remoteInstallable: boolean 7 | } -------------------------------------------------------------------------------- /src/data/stateCodes.ts: -------------------------------------------------------------------------------- 1 | export enum ConversationItemType { 2 | Message, 3 | ParticipantAction, 4 | ChatRenameAction 5 | } 6 | 7 | export enum ConversationPreviewType { 8 | Message, 9 | ChatCreation 10 | } 11 | 12 | export enum MessageModifierType { 13 | StatusUpdate, 14 | Sticker, 15 | Tapback 16 | } 17 | 18 | export enum TapbackType { 19 | Love, 20 | Like, 21 | Dislike, 22 | Laugh, 23 | Emphasis, 24 | Question 25 | } 26 | 27 | export enum ConnectionErrorCode { 28 | //Standard error codes 29 | Connection, 30 | Internet, 31 | InternalError, 32 | ExternalError, 33 | 34 | //Shared proxy error codes 35 | BadRequest, 36 | ClientOutdated, 37 | ServerOutdated, 38 | Unauthorized, 39 | 40 | //Connect proxy error codes 41 | ConnectNoGroup, 42 | ConnectNoCapacity, 43 | ConnectAccountValidation, 44 | ConnectNoActivation, 45 | ConnectOtherLocation 46 | } 47 | 48 | export enum MessageStatusCode { 49 | Unconfirmed, 50 | Idle, 51 | Sent, 52 | Delivered, 53 | Read 54 | } 55 | 56 | export interface MessageError { 57 | code: MessageErrorCode; 58 | detail?: string; 59 | } 60 | 61 | export enum MessageErrorCode { 62 | //App-provided error codes 63 | //LocalUnknown, //Unknown error (for example, a version upgrade where error codes change) 64 | LocalInvalidContent, //Invalid content 65 | LocalTooLarge, //Attachment too large 66 | LocalIO, //IO exception 67 | LocalNetwork, //Network exception 68 | LocalInternalError, //Internal exception 69 | 70 | //Server-provided error codes 71 | ServerUnknown, //An unknown response code was received from the server 72 | ServerExternal, //The server received an external error 73 | ServerBadRequest, //The server couldn't process the request 74 | ServerUnauthorized, //The server doesn't have permission to send messages 75 | ServerTimeout, //The server timed out the client's request 76 | 77 | //Apple-provided error codes 78 | AppleNoConversation, //The server couldn't find the requested conversation 79 | AppleNetwork, //The server received a network error 80 | AppleUnregistered, //The addressee doesn't have an iMessage account 81 | } 82 | 83 | export enum ParticipantActionType { 84 | Unknown, 85 | Join, 86 | Leave 87 | } 88 | 89 | export enum AttachmentRequestErrorCode { 90 | Timeout, //Request timed out 91 | BadResponse, //Bad response (packets out of order) 92 | ServerUnknown, //Unknown error from the server 93 | ServerNotFound, //Server file GUID not found 94 | ServerNotSaved, //Server file (on disk) not found 95 | ServerUnreadable, //Server no access to file 96 | ServerIO //Server I/O error 97 | } 98 | 99 | export enum CreateChatErrorCode { 100 | Network, //Network / timeout error 101 | ScriptError, //Some unknown AppleScript error 102 | BadRequest, //Invalid data received 103 | Unauthorized, //System rejected request 104 | NotSupported, //Operation not supported by the server 105 | UnknownExternal //Unknown error code received 106 | } 107 | 108 | export enum RemoteUpdateErrorCode { 109 | Unknown, //Unknown error 110 | Mismatch, //Client and server update information mismatch 111 | Download, //Server failed to download update 112 | BadPackage, //Server update validation failed 113 | Internal, //Internal error 114 | ReadOnlyVolume, //App bundle is not writable 115 | Timeout //Request timed out 116 | } 117 | 118 | export enum FaceTimeLinkErrorCode { 119 | Network, //Network error 120 | External //External error 121 | } 122 | 123 | export enum FaceTimeInitiateCode { 124 | OK, 125 | Network, //Network error 126 | Timeout, //Request timeout 127 | BadMembers, //Members are not available on FaceTime 128 | External //External error 129 | } -------------------------------------------------------------------------------- /src/data/unsubscribeCallback.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An interface that represents a callback to unsubscribe from an operation 3 | */ 4 | export default interface UnsubscribeCallback { 5 | (): void 6 | } -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {createRoot} from "react-dom/client"; 3 | import * as Sentry from "@sentry/react"; 4 | import SignInGate from "shared/components/SignInGate"; 5 | import AppTheme from "./components/control/AppTheme"; 6 | import {initializeApp} from "firebase/app"; 7 | import * as secrets from "./secrets"; 8 | import {setNotificationUtils} from "shared/interface/notification/notificationUtils"; 9 | import BrowserNotificationUtils from "shared/interface/notification/browserNotificationUtils"; 10 | import {setPlatformUtils} from "shared/interface/platform/platformUtils"; 11 | import BrowserPlatformUtils from "shared/interface/platform/browserPlatformUtils"; 12 | import {RemoteLibContextProvider} from "shared/state/remoteLibProvider"; 13 | 14 | //Set platform-specific utilities 15 | setNotificationUtils(new BrowserNotificationUtils()); 16 | setPlatformUtils(new BrowserPlatformUtils()); 17 | 18 | //Initializing Sentry 19 | if(WPEnv.ENVIRONMENT === "production") { 20 | Sentry.init({ 21 | dsn: secrets.sentryDSN, 22 | release: "airmessage-web@" + WPEnv.PACKAGE_VERSION, 23 | environment: WPEnv.ENVIRONMENT 24 | }); 25 | } 26 | 27 | //Initializing Firebase 28 | initializeApp(secrets.firebaseConfig); 29 | 30 | // Check that service workers are supported 31 | if(WPEnv.ENVIRONMENT === "production" && "serviceWorker" in navigator) { 32 | // Use the window load event to keep the page load performant 33 | window.addEventListener("load", () => { 34 | navigator.serviceWorker.register("/service-worker.js"); 35 | }); 36 | } 37 | 38 | //Initializing React 39 | const root = createRoot(document.getElementById("root")!); 40 | root.render( 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | ); -------------------------------------------------------------------------------- /src/interface/notification/browserNotificationUtils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallerNotificationAction, 3 | getMessageDescription, 4 | NotificationUtils 5 | } from "shared/interface/notification/notificationUtils"; 6 | import EventEmitter from "shared/util/eventEmitter"; 7 | import {LinkedConversation, MessageItem} from "shared/data/blocks"; 8 | import {playSoundNotification} from "shared/util/soundUtils"; 9 | import {PeopleState} from "shared/state/peopleState"; 10 | import {getMemberTitleSync} from "shared/util/conversationUtils"; 11 | 12 | export default class BrowserNotificationUtils extends NotificationUtils { 13 | private readonly messageNotificationBacklog = new Map(); 14 | private readonly messageNotificationClickEmitter = new EventEmitter(); 15 | 16 | private currentCallerNotification: Notification | undefined = undefined; 17 | private readonly callerNotificationActionEmitter = new EventEmitter(); 18 | 19 | initialize(): void { 20 | //Requesting permission to send notifications 21 | if(Notification.permission === "default") { 22 | setTimeout(() => Notification.requestPermission(), 1000); 23 | } 24 | } 25 | 26 | private notificationSoundPlayed = false; 27 | showMessageNotifications(conversation: LinkedConversation, messages: MessageItem[], peopleState: PeopleState) { 28 | //Ignoring if the app isn't allowed to send notifications, 29 | //or if there aren't any messages to notify 30 | if(Notification.permission !== "granted" || messages.length === 0) return; 31 | 32 | //Getting the conversation title to display in the notification 33 | const title = conversation.name ?? getMemberTitleSync(conversation.members, peopleState); 34 | 35 | //Getting the notification information 36 | const itemCount = messages.length; 37 | const chatGUID = conversation.guid; 38 | 39 | //Getting the count from the backlog 40 | let finalItemCount: number; 41 | const backlogEntry = this.messageNotificationBacklog.get(chatGUID); 42 | if(backlogEntry) { 43 | finalItemCount = backlogEntry[1] + itemCount; 44 | } else { 45 | finalItemCount = itemCount; 46 | } 47 | 48 | //Building the title based off of the item count 49 | let displayTitle: string; 50 | if(finalItemCount === 1) displayTitle = title; 51 | else displayTitle = `${title} • ${finalItemCount} new`; 52 | 53 | //Creating the notification 54 | const notification = new Notification(displayTitle, { 55 | body: getMessageDescription(messages[messages.length - 1]), 56 | tag: chatGUID 57 | }); 58 | 59 | //Notify listeners when the notification is clicked 60 | notification.onclick = () => { 61 | window.focus(); //Chromium 62 | this.messageNotificationClickEmitter.notify(chatGUID); 63 | }; 64 | 65 | //Remove the notification from the backlog when the notification is closed 66 | notification.onclose = () => this.messageNotificationBacklog.delete(chatGUID); 67 | 68 | //Updating the backlog 69 | this.messageNotificationBacklog.set(chatGUID, [notification, finalItemCount]); 70 | 71 | //Playing a sound (limit to once every second) 72 | if(!this.notificationSoundPlayed) { 73 | playSoundNotification(); 74 | this.notificationSoundPlayed = true; 75 | setTimeout(() => this.notificationSoundPlayed = false, 1000); 76 | } 77 | } 78 | 79 | dismissMessageNotifications(chatID: string) { 80 | //Fetching the entry from the backlog (and ignoring if it doesn't exist) 81 | const entry = this.messageNotificationBacklog.get(chatID); 82 | if(!entry) return; 83 | 84 | //Closing the notification and deleting it from the backlog 85 | entry[0].close(); 86 | this.messageNotificationBacklog.delete(chatID); 87 | } 88 | 89 | getMessageActionEmitter() { 90 | return this.messageNotificationClickEmitter; 91 | } 92 | 93 | updateCallerNotification(caller: string | undefined): void { 94 | if(caller !== undefined) { 95 | const notification = new Notification(caller, { 96 | body: "FaceTime", 97 | tag: "facetime", 98 | requireInteraction: true 99 | }); 100 | 101 | //Focus the window when the user selects the notification 102 | notification.onclick = () => window.focus(); 103 | notification.onclose = () => this.currentCallerNotification = undefined; 104 | this.currentCallerNotification = notification; 105 | } else { 106 | //Dismiss the notification 107 | this.currentCallerNotification?.close(); 108 | this.currentCallerNotification = undefined; 109 | } 110 | } 111 | 112 | getCallerActionEmitter(): EventEmitter { 113 | return this.callerNotificationActionEmitter; 114 | } 115 | } -------------------------------------------------------------------------------- /src/interface/notification/notificationUtils.ts: -------------------------------------------------------------------------------- 1 | import {Conversation, LinkedConversation, MessageItem} from "shared/data/blocks"; 2 | import {mimeTypeToPreview} from "shared/util/conversationUtils"; 3 | import {appleSendStyleBubbleInvisibleInk} from "shared/data/appleConstants"; 4 | import EventEmitter from "shared/util/eventEmitter"; 5 | import {PeopleState} from "shared/state/peopleState"; 6 | 7 | export abstract class NotificationUtils { 8 | /** 9 | * Initializes the notifications interface. 10 | * This function may show user prompts, so it should only be 11 | * called when the app is ready to send notifications. 12 | */ 13 | abstract initialize(): void; 14 | 15 | /** 16 | * Shows a notification. 17 | * The platform determines whether notifications are persisted or stacked. 18 | * @param conversation The conversation of the messages 19 | * @param messages An array of message items to notify, sorted oldest to newest 20 | * @param peopleState The people state to use to look up contacts 21 | */ 22 | abstract showMessageNotifications(conversation: LinkedConversation, messages: MessageItem[], peopleState: PeopleState): void; 23 | 24 | /** 25 | * Dismisses notifications for a certain chat 26 | * @param chatID The ID of the chat to remove notifications for 27 | */ 28 | abstract dismissMessageNotifications(chatID: string): void; 29 | 30 | /** 31 | * Gets an emitter that emits the conversation GUID for selected notifications 32 | */ 33 | abstract getMessageActionEmitter(): EventEmitter; 34 | 35 | /** 36 | * Shows a notification for the current caller, or dismisses the notification if there is no caller 37 | * @param caller The caller to display, or undefined to hide the notification 38 | */ 39 | abstract updateCallerNotification(caller: string | undefined): void; 40 | 41 | /** 42 | * Gets emitter that emits user actions for call notifications 43 | */ 44 | abstract getCallerActionEmitter(): EventEmitter; 45 | } 46 | 47 | export type CallerNotificationAction = {caller: string, action: "accept" | "decline"}; 48 | 49 | let notificationUtils: NotificationUtils; 50 | export function setNotificationUtils(value: NotificationUtils) { 51 | notificationUtils = value; 52 | } 53 | export function getNotificationUtils() { 54 | return notificationUtils; 55 | } 56 | 57 | /** 58 | * Gets the display message for a message item 59 | * @param message The message to generate a preview for 60 | */ 61 | export function getMessageDescription(message: MessageItem): string { 62 | if(message.sendStyle === appleSendStyleBubbleInvisibleInk) return "Message sent with Invisible Ink"; 63 | else if(message.text) return message.text; 64 | else if(message.attachments.length > 0) { 65 | if(message.attachments.length === 1) return mimeTypeToPreview(message.attachments[0].type); 66 | else return `${message.attachments.length} attachments`; 67 | } 68 | else return "Unknown message"; 69 | } -------------------------------------------------------------------------------- /src/interface/people/peopleUtils.ts: -------------------------------------------------------------------------------- 1 | export enum AddressType { 2 | Email = "email", 3 | Phone = "phone" 4 | } 5 | 6 | export interface AddressData { 7 | value: string; 8 | displayValue: string; 9 | label?: string; 10 | type: AddressType; 11 | } 12 | 13 | export interface PersonData { 14 | id: string; 15 | name?: string; 16 | avatar?: string; 17 | addresses: AddressData[]; 18 | } 19 | 20 | export class PeopleNoPermissionError extends Error { 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/interface/platform/browserPlatformUtils.ts: -------------------------------------------------------------------------------- 1 | import {PlatformUtils} from "shared/interface/platform/platformUtils"; 2 | 3 | export default class BrowserPlatformUtils extends PlatformUtils { 4 | initializeActivations() { 5 | } 6 | 7 | getChatActivationEmitter() { 8 | return undefined; 9 | } 10 | 11 | hasFocus() { 12 | return Promise.resolve(document.visibilityState === "visible"); 13 | } 14 | 15 | getExtraEmailDetails() { 16 | return Promise.resolve({}); 17 | } 18 | } -------------------------------------------------------------------------------- /src/interface/platform/platformUtils.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "shared/util/eventEmitter"; 2 | 3 | export abstract class PlatformUtils { 4 | /** 5 | * Checks for pending activations and sets up a listener for new activations. 6 | * This method should be called when the app is prepared to handle activations. 7 | */ 8 | abstract initializeActivations(): void; 9 | 10 | /** 11 | * Gets the event emitter for when a chat is activated 12 | */ 13 | abstract getChatActivationEmitter(): EventEmitter | undefined; 14 | 15 | /** 16 | * Checks if the app currently has focus. 17 | */ 18 | abstract hasFocus(): Promise; 19 | 20 | /** 21 | * Gets extra items to add to the device information section of the feedback email 22 | */ 23 | abstract getExtraEmailDetails(): Promise<{[key: string]: string}>; 24 | } 25 | 26 | let platformUtils: PlatformUtils; 27 | export function setPlatformUtils(value: PlatformUtils) { 28 | platformUtils = value; 29 | } 30 | export function getPlatformUtils() { 31 | return platformUtils; 32 | } -------------------------------------------------------------------------------- /src/resources/audio/message_in.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airmessage/airmessage-web/81c65b4068f56db25cb608ae9378c32ae4fe79fb/src/resources/audio/message_in.wav -------------------------------------------------------------------------------- /src/resources/audio/message_out.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airmessage/airmessage-web/81c65b4068f56db25cb608ae9378c32ae4fe79fb/src/resources/audio/message_out.wav -------------------------------------------------------------------------------- /src/resources/audio/notification.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airmessage/airmessage-web/81c65b4068f56db25cb608ae9378c32ae4fe79fb/src/resources/audio/notification.wav -------------------------------------------------------------------------------- /src/resources/audio/tapback.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airmessage/airmessage-web/81c65b4068f56db25cb608ae9378c32ae4fe79fb/src/resources/audio/tapback.wav -------------------------------------------------------------------------------- /src/resources/icons/control-send.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/resources/icons/logo-airmessage-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/resources/icons/logo-google.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/resources/icons/tile-airmessage.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Icon AirMessage 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/resources/icons/tile-mac.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Icon Mac 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/resources/text/changelog.md: -------------------------------------------------------------------------------- 1 | This update improves integration with Google sign-in and Google Contacts on browsers with tracking prevention 2 | -------------------------------------------------------------------------------- /src/secrets.default.ts: -------------------------------------------------------------------------------- 1 | export const connectHostname = "wss://connect-open.airmessage.org"; 2 | 3 | export const googleApiKey = "AIzaSyDE2nDAKL6smwPmZIBy1IP8-x_pTOqpzfM"; 4 | export const googleClientID = "526640769548-gv7t20cb7evjgmnngnl804gm0ec3kl8h.apps.googleusercontent.com"; 5 | export const googleClientSecret = "AHUS5_B5Ahmas7ioaoiBact0"; 6 | 7 | export const firebaseConfig = { 8 | apiKey: "AIzaSyDE2nDAKL6smwPmZIBy1IP8-x_pTOqpzfM", 9 | authDomain: "airmessage-open.firebaseapp.com", 10 | projectId: "airmessage-open", 11 | storageBucket: "airmessage-open.appspot.com", 12 | messagingSenderId: "526640769548", 13 | appId: "1:526640769548:web:2b0b11b66424b3ce805c29" 14 | }; 15 | 16 | export const sentryDSN = undefined; 17 | 18 | export const jwkLocalEncryption: JsonWebKey = { 19 | kty: "oct", 20 | k: "s9lDeHtl0rh-3FpBDZwQvw", 21 | alg: "A128GCM" 22 | }; -------------------------------------------------------------------------------- /src/state/localMessageCache.ts: -------------------------------------------------------------------------------- 1 | import {ConversationItem} from "shared/data/blocks"; 2 | 3 | //The app-wide cache for locally-stored messages 4 | const localMessageCache = new Map(); 5 | export default localMessageCache; -------------------------------------------------------------------------------- /src/state/peopleState.tsx: -------------------------------------------------------------------------------- 1 | import {AddressData, AddressType, PeopleNoPermissionError, PersonData} from "shared/interface/people/peopleUtils"; 2 | import React, {useCallback, useEffect, useRef, useState} from "react"; 3 | import {formatAddress} from "shared/util/conversationUtils"; 4 | import {parsePhoneNumberFromString} from "libphonenumber-js"; 5 | import {useCancellableEffect} from "shared/util/hookUtils"; 6 | 7 | export interface PeopleState { 8 | needsPermission: boolean, 9 | getPerson(address: string): PersonData | undefined; 10 | allPeople: PersonData[] | undefined; 11 | } 12 | 13 | export const PeopleContext = React.createContext({ 14 | needsPermission: false, 15 | getPerson: () => undefined, 16 | allPeople: undefined 17 | }); 18 | 19 | export function PeopleContextProvider(props: { 20 | children?: React.ReactNode; 21 | ready?: boolean; 22 | }) { 23 | const [needsPermission, setNeedsPermission] = useState(false); 24 | const [peopleData, setPeopleData] = useState(undefined); 25 | 26 | const ready = props.ready; 27 | useCancellableEffect((addPromise) => { 28 | if(!ready) return; 29 | addPromise(gapiLoadPeople()) 30 | .then(setPeopleData) 31 | .catch((error) => { 32 | if(error instanceof PeopleNoPermissionError) { 33 | setNeedsPermission(true); 34 | } else { 35 | console.warn(`Failed to load people: ${error}`); 36 | } 37 | }); 38 | }, [ready, setPeopleData]); 39 | 40 | const getPerson = useCallback((address: string): PersonData | undefined => { 41 | //Check if people are loaded 42 | if(peopleData === undefined) { 43 | return undefined; 44 | } 45 | 46 | //Format the address 47 | let formattedAddress = address; 48 | if(!address.includes("@")) { 49 | const phone = parsePhoneNumberFromString(address); 50 | if(phone !== undefined) formattedAddress = phone.number.toString(); 51 | } 52 | 53 | return peopleData.personMap.get(formattedAddress); 54 | }, [peopleData]); 55 | 56 | return ( 57 | 62 | {props.children} 63 | 64 | ); 65 | } 66 | 67 | class GAPIPeopleError extends Error { 68 | readonly response: gapi.client.HttpRequestRejected; 69 | constructor(response: gapi.client.HttpRequestRejected) { 70 | super(response.body); 71 | this.response = response; 72 | } 73 | } 74 | 75 | interface LoadedPeopleData { 76 | personArray: PersonData[]; 77 | personMap: Map; 78 | } 79 | 80 | async function gapiLoadPeople(): Promise { 81 | //Creating the return values 82 | const personArray: PersonData[] = []; 83 | const contactMap = new Map(); 84 | 85 | let nextPageToken: string | undefined = undefined; 86 | let requestIndex = 0; 87 | do { 88 | //Sleeping every 2 requests 89 | if(requestIndex > 0 && requestIndex % 2 === 0) { 90 | await new Promise(r => setTimeout(r, 1000)); 91 | } 92 | 93 | //Fetching contacts from Google 94 | const parameters = { 95 | resourceName: "people/me", 96 | personFields: "names,photos,emailAddresses,phoneNumbers", 97 | pageToken: nextPageToken, 98 | pageSize: 1000, 99 | sortOrder: "FIRST_NAME_ASCENDING", 100 | sources: ["READ_SOURCE_TYPE_CONTACT"] 101 | } as gapi.client.people.people.connections.ListParameters; 102 | 103 | let response: gapi.client.HttpRequestFulfilled; 104 | try { 105 | response = await gapi.client.people.people.connections.list(parameters); 106 | } catch(error) { 107 | const response = error as gapi.client.HttpRequestRejected; 108 | if(response.status === 401 || response.status === 403) { 109 | throw new PeopleNoPermissionError(); 110 | } else { 111 | throw new GAPIPeopleError(response); 112 | } 113 | } 114 | 115 | //Exit if there are no more connections 116 | if(!response.result.connections) break; 117 | 118 | //Iterating over the retrieved people 119 | for(const person of response.result.connections) { 120 | //Reading the person data 121 | const personData = googlePersonToPersonData(person); 122 | 123 | //Adding the person to the array 124 | personArray.push(personData); 125 | 126 | //Sorting the person's information with their address as the key 127 | for(const address of personData.addresses) { 128 | if(contactMap.has(address.value)) continue; 129 | contactMap.set(address.value, personData); 130 | } 131 | } 132 | 133 | //Setting the next page token 134 | nextPageToken = response.result.nextPageToken; 135 | 136 | //Logging the event 137 | console.log("Loaded contacts request index " + requestIndex + " / " + nextPageToken); 138 | 139 | //Incrementing the request index 140 | requestIndex++; 141 | } while(nextPageToken); 142 | 143 | //Returning the data 144 | return { 145 | personArray: personArray, 146 | personMap: contactMap 147 | }; 148 | } 149 | 150 | function googlePersonToPersonData(person: gapi.client.people.Person): PersonData { 151 | const id = person.resourceName; 152 | const name = person.names?.[0].displayName; 153 | const avatar = person.photos?.[0]?.url; 154 | const addresses: AddressData[] = [ 155 | ...person.emailAddresses?.reduce((accumulator: AddressData[], address) => { 156 | if(address.value !== undefined) { 157 | accumulator.push({value: address.value, displayValue: address.value, label: address.formattedType, type: AddressType.Email}); 158 | } 159 | return accumulator; 160 | }, []) ?? [], 161 | ...person.phoneNumbers?.reduce((accumulator: AddressData[], address) => { 162 | if(address.canonicalForm !== undefined) { 163 | accumulator.push({value: address.canonicalForm, displayValue: formatAddress(address.canonicalForm), label: address.formattedType, type: AddressType.Phone}); 164 | } 165 | return accumulator; 166 | }, []) ?? [], 167 | ]; 168 | 169 | return { 170 | id: id, 171 | name: name, 172 | avatar: avatar, 173 | addresses: addresses 174 | } as PersonData; 175 | } 176 | -------------------------------------------------------------------------------- /src/state/remoteLibProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, {useMemo, useState} from "react"; 2 | import * as secrets from "shared/secrets"; 3 | 4 | /** 5 | * Keeps track of which libraries are available to use 6 | */ 7 | export interface RemoteLibState { 8 | gapiLoaded: boolean; 9 | promiseGAPI: Promise; 10 | } 11 | 12 | export const RemoteLibContext = React.createContext({ 13 | gapiLoaded: false, 14 | get promiseGAPI() { 15 | return Promise.reject("promiseGAPI unavailable since RemoteLibContext is not available"); 16 | } 17 | }); 18 | 19 | /** 20 | * Loads libraries in the background, and exposes to 21 | * consumers when libraries are available 22 | */ 23 | export function RemoteLibContextProvider(props: {children?: React.ReactNode}) { 24 | const [gapiLoaded, setGAPILoaded] = useState(false); 25 | 26 | //Load the Google platform script 27 | const promiseGAPI = useMemo(async () => { 28 | //Add the script element and wait for it to load 29 | await new Promise((resolve) => { 30 | const script = document.createElement("script"); 31 | script.setAttribute("src", "https://apis.google.com/js/api.js"); 32 | script.onload = resolve; 33 | document.head.appendChild(script); 34 | }); 35 | 36 | //Load the client 37 | await new Promise((resolve) => { 38 | gapi.load("client", resolve); 39 | }); 40 | 41 | //Load the people endpoint 42 | await gapi.client.init({ 43 | apiKey: secrets.googleApiKey, 44 | discoveryDocs: ["https://people.googleapis.com/$discovery/rest?version=v1"] 45 | }); 46 | 47 | //Update the state 48 | setGAPILoaded(true); 49 | }, [setGAPILoaded]); 50 | 51 | return ( 52 | 56 | {props.children} 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/util/addressHelper.ts: -------------------------------------------------------------------------------- 1 | import {isValidPhoneNumber as libIsValidPhoneNumber, parsePhoneNumberFromString} from "libphonenumber-js"; 2 | 3 | const regexEmail = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; 4 | 5 | /** 6 | * Gets if a string is a valid email address 7 | */ 8 | export function isValidEmailAddress(value: string): boolean { 9 | return !!value.match(regexEmail); 10 | } 11 | 12 | /** 13 | * Gets if a string is valid phone number 14 | */ 15 | export function isValidPhoneNumber(value: string): boolean { 16 | return libIsValidPhoneNumber(value, "US"); 17 | } 18 | 19 | /** 20 | * Gets if a string is valid address 21 | */ 22 | export function isValidAddress(value: string): boolean { 23 | return isValidEmailAddress(value) || isValidPhoneNumber(value); 24 | } 25 | 26 | /** 27 | * Normalizes an address 28 | */ 29 | export function normalizeAddress(value: string): string { 30 | //Format phone numbers as E164 31 | const phoneNumber = parsePhoneNumberFromString(value, "US"); 32 | if(phoneNumber?.isValid()) { 33 | return phoneNumber.format("E.164"); 34 | } 35 | 36 | //Email addresses can't be formatted 37 | return value; 38 | } -------------------------------------------------------------------------------- /src/util/arrayUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Checks if 2 arrays contain the same values 3 | * @param array1 The first array to compare 4 | * @param array2 The second array to compare 5 | * @param mapFn A function to apply that will map all values 6 | * @param compareFn A function used to compare two values 7 | */ 8 | export function arrayContainsAll(array1: T[], array2: T[], mapFn: (a: T) => R, compareFn?: ((a: R, b: R) => number)): boolean; 9 | export function arrayContainsAll(array1: T[], array2: T[], mapFn?: undefined, compareFn?: ((a: T, b: T) => number)): boolean; 10 | export function arrayContainsAll(array1: unknown[], array2: unknown[], mapFn: (a: unknown) => unknown = (a) => a, compareFn?: ((a: unknown, b: unknown) => number)): boolean { 11 | //Make sure the arrays have the same length 12 | if(array1.length !== array2.length) return false; 13 | 14 | //Map and sort the arrays 15 | const array1Sorted = array1.map(mapFn).sort(compareFn); 16 | const array2Sorted = array2.map(mapFn).sort(compareFn); 17 | 18 | //Return false if any of the elements don't match 19 | for(let i = 0; i < array1Sorted.length; i++) { 20 | if(compareFn !== undefined) { 21 | if(compareFn(array1Sorted[i], array2Sorted[i]) !== 0) { 22 | return false; 23 | } 24 | } else { 25 | if(array1Sorted[i] !== array2Sorted[i]) { 26 | return false; 27 | } 28 | } 29 | } 30 | 31 | return true; 32 | } 33 | 34 | /** 35 | * Creates a map of array entries grouped by a property 36 | */ 37 | export function groupArray(array: T[], keyExtractor: (item: T) => K): Map { 38 | return array.reduce>((accumulator, item) => { 39 | const key = keyExtractor(item); 40 | 41 | const itemArray = accumulator.get(key); 42 | if(itemArray !== undefined) itemArray.push(item); 43 | else accumulator.set(key, [item]); 44 | return accumulator; 45 | }, new Map()); 46 | } -------------------------------------------------------------------------------- /src/util/authUtils.ts: -------------------------------------------------------------------------------- 1 | import {useEffect} from "react"; 2 | import {googleScope} from "shared/constants"; 3 | import {googleClientID, googleClientSecret} from "shared/secrets"; 4 | 5 | const stateCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 6 | const stateStorageKey = "oauthState"; 7 | 8 | const redirectURI = window.location.origin + "/"; 9 | 10 | export interface OAuthTokenResult { 11 | id_token: string; 12 | access_token: string; 13 | expires_in: number; 14 | refresh_token: string; 15 | scope: string; 16 | } 17 | 18 | export interface OAuthRefreshResult { 19 | access_token: string; 20 | expires_in: number; 21 | scope: string; 22 | } 23 | 24 | /** 25 | * Securely generates a random string of length 26 | */ 27 | function generateRandomString(length: number) { 28 | const buffer = new Uint8Array(length); 29 | crypto.getRandomValues(buffer); 30 | 31 | const state = []; 32 | for(const byte of buffer) { 33 | state.push(stateCharacters[byte % stateCharacters.length]); 34 | } 35 | return state.join(""); 36 | } 37 | 38 | /** 39 | * Generates an OAuth2 URL, saves its state to 40 | * local storage, and redirects the browser 41 | */ 42 | function requestAuthorization(loginHint?: string) { 43 | const state = generateRandomString(8); 44 | localStorage.setItem(stateStorageKey, state); 45 | 46 | const url = new URL("https://accounts.google.com/o/oauth2/v2/auth"); 47 | url.searchParams.set("client_id", googleClientID); 48 | url.searchParams.set("redirect_uri", redirectURI); 49 | url.searchParams.set("response_type", "code"); 50 | url.searchParams.set("scope", googleScope); 51 | url.searchParams.set("state", state); 52 | url.searchParams.set("prompt", "consent select_account"); 53 | url.searchParams.set("access_type", "offline"); 54 | url.searchParams.set("include_granted_scopes", true.toString()); 55 | if(loginHint !== undefined) { 56 | url.searchParams.set("login_hint", loginHint); 57 | } 58 | 59 | window.location.assign(url); 60 | } 61 | 62 | /** 63 | * Checks the URL for OAuth2 return values, and returns a code if available 64 | */ 65 | function continueAuthorization(): string | undefined { 66 | //Read the expected state 67 | const localState = localStorage.getItem(stateStorageKey); 68 | if(localState === null) return undefined; 69 | localStorage.removeItem(stateStorageKey); 70 | 71 | //Parse URL parameters 72 | const urlParams = new URLSearchParams(window.location.search); 73 | 74 | //Match the state 75 | const urlState = urlParams.get("state"); 76 | const urlCode = urlParams.get("code"); 77 | const urlError = urlParams.get("error"); 78 | if(urlState !== localState) return undefined; 79 | 80 | //Check for a code 81 | if(urlError !== null) { 82 | return undefined; 83 | } 84 | if(urlCode === null) { 85 | return undefined; 86 | } 87 | 88 | //Reset the browser URL 89 | const updatedURL = new URL(window.location.href); 90 | updatedURL.search = ""; 91 | window.history.replaceState(null, "", updatedURL); 92 | 93 | return urlCode; 94 | } 95 | 96 | /** 97 | * Exchanges an OAuth2 code with Google 98 | */ 99 | function exchangeOAuth2Code( 100 | code: string, 101 | redirectURI: string 102 | ): Promise { 103 | return fetch("https://oauth2.googleapis.com/token", { 104 | method: "POST", 105 | headers: { 106 | "Content-Type": "application/x-www-form-urlencoded" 107 | }, 108 | body: new URLSearchParams({ 109 | "client_id": googleClientID, 110 | "client_secret": googleClientSecret, 111 | "code": code, 112 | "grant_type": "authorization_code", 113 | "redirect_uri": redirectURI 114 | }) 115 | }).then(async (response) => { 116 | const data = await response.json(); 117 | if(!response.ok) { 118 | const error: string = data.error; 119 | throw new Error(`Got HTTP response ${response.status} (${error})`); 120 | } 121 | 122 | return data; 123 | }); 124 | } 125 | 126 | /** 127 | * Exchanges an OAuth2 code with Google 128 | */ 129 | function refreshAccessToken( 130 | refreshToken: string, 131 | ): Promise { 132 | return fetch("https://oauth2.googleapis.com/token", { 133 | method: "POST", 134 | headers: { 135 | "Content-Type": "application/x-www-form-urlencoded" 136 | }, 137 | body: new URLSearchParams({ 138 | "client_id": googleClientID, 139 | "client_secret": googleClientSecret, 140 | "grant_type": "refresh_token", 141 | "refresh_token": refreshToken 142 | }) 143 | }).then((response) => { 144 | if(!response.ok) { 145 | throw new Error(`Got HTTP response ${response.status}`); 146 | } 147 | 148 | return response.json(); 149 | }); 150 | } 151 | 152 | //Check authorization state on page load 153 | const initialAuthorizationCode = continueAuthorization(); 154 | 155 | /** 156 | * Hook function for integrating with Google sign-in 157 | * @param callback A callback invoked when the user signs in 158 | * @return 159 | * - Whether this session was launched in response to a sign-in session 160 | * - A function to start the sign-in process 161 | * - A function to exchange a refresh token for an access token 162 | */ 163 | export function useGoogleSignIn(callback: (result: OAuthTokenResult) => void): [boolean, (loginHint?: string) => void, (refreshToken: string) => Promise] { 164 | useEffect(() => { 165 | //Check for an authorization code 166 | if(initialAuthorizationCode === undefined) return; 167 | 168 | //Exchange the code and invoke the callback 169 | exchangeOAuth2Code(initialAuthorizationCode, redirectURI) 170 | .then((result) => callback(result)); 171 | }, [callback]); 172 | 173 | const isAuthResponseSession = initialAuthorizationCode !== undefined; 174 | 175 | return [isAuthResponseSession, requestAuthorization, refreshAccessToken]; 176 | } 177 | 178 | export class InvalidTokenError extends Error { 179 | 180 | } 181 | -------------------------------------------------------------------------------- /src/util/avatarUtils.ts: -------------------------------------------------------------------------------- 1 | import {hashString} from "shared/util/hashUtils"; 2 | 3 | const colors = [ 4 | "#FF1744", //Red 5 | "#F50057", //Pink 6 | "#B317CF", //Purple 7 | "#703BE3", //Dark purple 8 | "#3D5AFE", //Indigo 9 | "#2979FF", //Blue 10 | "#00B0FF", //Light blue 11 | "#00B8D4", //Cyan 12 | "#00BFA5", //Teal 13 | "#00C853", //Green 14 | "#5DD016", //Light green 15 | "#99CC00", //Lime green 16 | "#F2CC0D", //Yellow 17 | "#FFC400", //Amber 18 | "#FF9100", //Orange 19 | "#FF3D00", //Deep orange 20 | ]; 21 | 22 | /** 23 | * Gets a pseudorandom color to use for a certain contact address 24 | */ 25 | export function colorFromContact(contact: string): string { 26 | return colors[Math.abs(hashString(contact)) % colors.length]; 27 | } -------------------------------------------------------------------------------- /src/util/browserUtils.ts: -------------------------------------------------------------------------------- 1 | export function downloadBlob(data: Blob, type: string, name: string) { 2 | const blobURL = URL.createObjectURL(data); 3 | downloadURL(blobURL, type, name); 4 | URL.revokeObjectURL(blobURL); 5 | } 6 | 7 | export function downloadURL(url: string, type: string, name: string) { 8 | const link = document.createElement("a"); 9 | link.download = name; 10 | link.href = url; 11 | link.click(); 12 | link.remove(); 13 | } -------------------------------------------------------------------------------- /src/util/cancellablePromise.ts: -------------------------------------------------------------------------------- 1 | import UnsubscribeCallback from "shared/data/unsubscribeCallback"; 2 | 3 | /** 4 | * Wraps a promise, returning the wrapped promise and a callback 5 | * that prevents the wrapped promise from receiving further updates 6 | * from the original promise 7 | */ 8 | export function makeCancellablePromise(promise: Promise): [Promise, UnsubscribeCallback] { 9 | let isCancelled = false; 10 | 11 | const wrappedPromise = new Promise((resolve, reject) => { 12 | promise 13 | .then((val) => { 14 | if(!isCancelled) { 15 | resolve(val); 16 | } 17 | }) 18 | .catch((error) => { 19 | if(!isCancelled) { 20 | reject(error); 21 | } 22 | }); 23 | }); 24 | const cancelPromise = () => { 25 | isCancelled = true; 26 | }; 27 | 28 | return [wrappedPromise, cancelPromise]; 29 | } 30 | 31 | /** 32 | * Wraps a promise, passing a callback that prevents the wrapped promise 33 | * from receiving further updates to unsubscribeConsumer 34 | */ 35 | export function installCancellablePromise( 36 | promise: Promise, 37 | unsubscribeConsumer: (callback: UnsubscribeCallback) => void 38 | ): Promise { 39 | const [wrappedPromise, unsubscribeCallback] = makeCancellablePromise(promise); 40 | unsubscribeConsumer(unsubscribeCallback); 41 | return wrappedPromise; 42 | } -------------------------------------------------------------------------------- /src/util/dateUtils.ts: -------------------------------------------------------------------------------- 1 | import {DateTime} from "luxon"; 2 | 3 | const timeMinute = 60 * 1000; 4 | const timeHour = timeMinute * 60; 5 | 6 | const bulletSeparator = " • "; 7 | 8 | //Used in the sidebar 9 | export function getLastUpdateStatusTime(date: Date): string { 10 | const dateNow = new Date(); 11 | const timeDiff = dateNow.getTime() - date.getTime(); 12 | 13 | //Just now (1 minute) 14 | if(timeDiff < timeMinute) { 15 | return "Just now"; 16 | } 17 | 18 | //Within the hour 19 | if(timeDiff < timeHour) { 20 | const minutes = Math.floor(timeDiff / timeMinute); 21 | return `${minutes} min`; 22 | } 23 | 24 | //Within the day (14:11) 25 | if(checkSameDay(date, dateNow)) { 26 | return DateTime.fromJSDate(date).toLocaleString(DateTime.TIME_SIMPLE)!; 27 | //return dayjs(date).format('LT'); 28 | } 29 | 30 | //Within the week (Sun) 31 | { 32 | const compareDate = new Date(dateNow.getFullYear(), dateNow.getMonth(), dateNow.getDate() - 7); //Today (now) -> One week ago 33 | if(compareDates(date, compareDate) > 0) { 34 | return DateTime.fromJSDate(date).toFormat("ccc"); 35 | //return dayjs(date).format("ddd"); 36 | } 37 | } 38 | 39 | //Within the year (Dec 9) 40 | { 41 | const compareDate = new Date(dateNow.getFullYear() - 1, dateNow.getMonth(), dateNow.getDate()); //Today (now) -> One year ago 42 | if(compareDates(date, compareDate) > 0) { 43 | return DateTime.fromJSDate(date).toFormat("LLL d"); 44 | //return dayjs(date).format("MMM D"); 45 | } 46 | } 47 | 48 | //Anytime (Dec 2018) 49 | //return dayjs(date).format("MMM YYYY") 50 | return DateTime.fromJSDate(date).toFormat("LLL yyyy"); 51 | } 52 | 53 | //Used in time separators between messages 54 | export function getTimeDivider(date: Date): string { 55 | const dateNow = new Date(); 56 | const luxon = DateTime.fromJSDate(date); 57 | const formattedTime = luxon.toLocaleString(DateTime.TIME_SIMPLE)!; 58 | //const formattedTime = dayjs(date).format('LT'); 59 | 60 | //Same day (12:30) 61 | if(checkSameDay(date, dateNow)) { 62 | return formattedTime; 63 | } 64 | 65 | //Yesterday (Yesterday • 12:30) 66 | { 67 | const compareDate = new Date(dateNow.getFullYear(), dateNow.getMonth(), dateNow.getDate() - 1); //Today (now) -> Yesterday 68 | if(checkSameDay(date, compareDate)) { 69 | return "Yesterday" + bulletSeparator + formattedTime; 70 | } 71 | } 72 | 73 | //Same 7-day period (Sunday • 12:30) 74 | { 75 | const compareDate = new Date(dateNow.getFullYear(), dateNow.getMonth(), dateNow.getDate() - 7); //Today (now) -> One week ago 76 | if(compareDates(date, compareDate) > 0) { 77 | return luxon.toFormat("cccc") + bulletSeparator + formattedTime; 78 | //return dayjs(date).format("dddd") + bulletSeparator + formattedTime; 79 | } 80 | } 81 | 82 | //Same year (Sunday, December 9 • 12:30) 83 | { 84 | const compareDate = new Date(dateNow.getFullYear() - 1, dateNow.getMonth(), dateNow.getDate()); //Today (now) -> One year ago 85 | if(compareDates(date, compareDate) > 0) { 86 | return luxon.toFormat("cccc, LLLL d") + bulletSeparator + formattedTime; 87 | //return dayjs(date).format("dddd, MMMM D") + bulletSeparator + formattedTime; 88 | } 89 | } 90 | 91 | //Different years (December 9, 2018 • 12:30) 92 | return luxon.toFormat("LLLL d, yyyy") + bulletSeparator + formattedTime; 93 | //return dayjs(date).format("ll") + bulletSeparator + formattedTime; 94 | } 95 | 96 | //Used in read receipts 97 | export function getDeliveryStatusTime(date: Date): string { 98 | const dateNow = new Date(); 99 | const luxon = DateTime.fromJSDate(date); 100 | const formattedTime = luxon.toLocaleString(DateTime.TIME_SIMPLE)!; 101 | 102 | //Same day (12:30) 103 | if(checkSameDay(date, dateNow)) { 104 | return formattedTime; 105 | } 106 | 107 | //Yesterday (Yesterday) 108 | { 109 | const compareDate = new Date(dateNow.getFullYear(), dateNow.getMonth(), dateNow.getDate() - 1); //Today (now) -> Yesterday 110 | if(checkSameDay(date, compareDate)) { 111 | return "Yesterday"; 112 | } 113 | } 114 | 115 | //Same 7-day period (Sunday) 116 | { 117 | const compareDate = new Date(dateNow.getFullYear(), dateNow.getMonth(), dateNow.getDate() - 7); //Today (now) -> One week ago 118 | if(compareDates(date, compareDate) > 0) { 119 | return luxon.toFormat("cccc"); 120 | } 121 | } 122 | 123 | //Same year (Dec 9) 124 | { 125 | const compareDate = new Date(dateNow.getFullYear() - 1, dateNow.getMonth(), dateNow.getDate()); //Today (now) -> One year ago 126 | if(compareDates(date, compareDate) > 0) { 127 | return luxon.toFormat("LLL d"); 128 | } 129 | } 130 | 131 | //Different years (Dec 9, 2018) 132 | return luxon.toFormat("LLL d, yyyy") + bulletSeparator + formattedTime; 133 | } 134 | 135 | function checkSameDay(date1: Date, date2: Date): boolean { 136 | return date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth() && date1.getDate() === date2.getDate(); 137 | } 138 | 139 | function compareDates(date1: Date, date2: Date): number { 140 | if(date1.getFullYear() < date2.getFullYear()) return -1; 141 | else if(date1.getFullYear() > date2.getFullYear()) return 1; 142 | else if(date1.getMonth() < date2.getMonth()) return -1; 143 | else if(date1.getMonth() > date2.getMonth()) return 1; 144 | else if(date1.getDate() < date2.getDate()) return -1; 145 | else if(date1.getDate() > date2.getDate()) return 1; 146 | else return 0; 147 | } -------------------------------------------------------------------------------- /src/util/emitterPromiseTuple.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "shared/util/eventEmitter"; 2 | 3 | /** 4 | * An object that contains an even emitter and a promise 5 | */ 6 | export default interface EmitterPromiseTuple { 7 | emitter: EventEmitter; 8 | promise: Promise

; 9 | } -------------------------------------------------------------------------------- /src/util/encodingUtils.ts: -------------------------------------------------------------------------------- 1 | import {Base64} from "js-base64"; 2 | 3 | /** 4 | * Encodes the passed ArrayBuffer to a base64 string 5 | */ 6 | export function encodeBase64(value: ArrayBuffer): string { 7 | return Base64.fromUint8Array(new Uint8Array(value)); 8 | } 9 | 10 | /** 11 | * Decodes a base64 string to an ArrayBuffer 12 | */ 13 | export function decodeBase64(value: string): ArrayBuffer { 14 | return Base64.toUint8Array(value); 15 | } 16 | 17 | /** 18 | * Encodes an ArrayBuffer to a hex string 19 | */ 20 | //https://stackoverflow.com/a/40031979 21 | export function arrayBufferToHex(data: ArrayBuffer): string { 22 | return Array.prototype.map.call(new Uint8Array(data), (x: number) => ("00" + x.toString(16)).slice(-2)).join(""); 23 | } -------------------------------------------------------------------------------- /src/util/encryptionUtils.ts: -------------------------------------------------------------------------------- 1 | //Creating the constants 2 | const saltLen = 8; //8 bytes 3 | const ivLen = 12; //12 bytes (instead of 16 because of GCM) 4 | const algorithm = "PBKDF2"; 5 | const hash = "SHA-256"; 6 | const cipherTransformation = "AES-GCM"; 7 | const keyIterationCount = 10000; 8 | const keyLength = 128; //128 bits 9 | 10 | //Whether a request has been put in to initialize the crypto password, even if undefined 11 | let cryptoPasswordSet = false; 12 | let userKey: CryptoKey | undefined; 13 | 14 | /** 15 | * Sets the password to use for future cryptographic operations 16 | */ 17 | export async function setCryptoPassword(password: string | undefined) { 18 | cryptoPasswordSet = true; 19 | 20 | if(password == undefined) { 21 | userKey = undefined; 22 | } else { 23 | userKey = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveKey"]); 24 | } 25 | } 26 | 27 | /** 28 | * Gets whether {@link setCryptoPassword} has been called once 29 | * (even if the password as undefined) 30 | */ 31 | export function isCryptoPasswordSet() { 32 | return cryptoPasswordSet; 33 | } 34 | 35 | /** 36 | * Gets if a valid crypto password is available to use 37 | */ 38 | export function isCryptoPasswordAvailable() { 39 | return userKey !== undefined; 40 | } 41 | 42 | /** 43 | * Encrypts the provided ArrayBuffer with the crypto password 44 | */ 45 | export async function encryptData(inData: ArrayBuffer): Promise { 46 | //Generating random data 47 | const salt = new Uint8Array(saltLen); 48 | crypto.getRandomValues(salt); 49 | const iv = new Uint8Array(ivLen); 50 | crypto.getRandomValues(iv); 51 | 52 | //Creating the key 53 | const derivedKey = await crypto.subtle.deriveKey({name: algorithm, salt: salt, iterations: keyIterationCount, hash: hash}, 54 | userKey!, 55 | {name: cipherTransformation, length: keyLength}, 56 | false, 57 | ["encrypt"]); 58 | 59 | //Encrypting the data 60 | const encrypted = await crypto.subtle.encrypt({name: cipherTransformation, iv: iv}, derivedKey, inData); 61 | 62 | //Returning the data 63 | const returnData = new Uint8Array(saltLen + ivLen + encrypted.byteLength); 64 | returnData.set(salt, 0); 65 | returnData.set(iv, saltLen); 66 | returnData.set(new Uint8Array(encrypted), saltLen + ivLen); 67 | return returnData.buffer; 68 | } 69 | 70 | /** 71 | * Decrypts the provided ArrayBuffer with the crypto password 72 | */ 73 | export async function decryptData(inData: ArrayBuffer): Promise { 74 | //Reading the data 75 | const salt = inData.slice(0, saltLen); 76 | const iv = inData.slice(saltLen, saltLen + ivLen); 77 | const data = inData.slice(saltLen + ivLen); 78 | 79 | //Creating the key 80 | const derivedKey = await crypto.subtle.deriveKey({name: algorithm, salt: salt, iterations: keyIterationCount, hash: hash}, 81 | userKey!, 82 | {name: cipherTransformation, length: keyLength}, 83 | false, 84 | ["decrypt"]); 85 | 86 | //Decrypting the data 87 | return await crypto.subtle.decrypt({name: cipherTransformation, iv: iv}, derivedKey, data); 88 | } -------------------------------------------------------------------------------- /src/util/eventEmitter.ts: -------------------------------------------------------------------------------- 1 | import UnsubscribeCallback from "shared/data/unsubscribeCallback"; 2 | 3 | /** 4 | * A listener that receives updates from an EventEmitter 5 | */ 6 | export interface EventEmitterListener { 7 | (event: T): void; 8 | } 9 | 10 | /** 11 | * A stream of events that can be notified or subscribed to 12 | */ 13 | export default class EventEmitter { 14 | private readonly listeners: EventEmitterListener[] = []; 15 | 16 | /** 17 | * Subscribes to updates from this EventEmitter 18 | * @param listener The listener to subscribe to this emitter 19 | * @param unsubscribeConsumer An optional callback function that 20 | * will receive an instance of this subscription's unsubscribe callback 21 | */ 22 | public subscribe(listener: EventEmitterListener, unsubscribeConsumer?: (callback: UnsubscribeCallback) => void): UnsubscribeCallback { 23 | this.listeners.push(listener); 24 | 25 | const unsubscribeCallback = () => this.unsubscribe(listener); 26 | unsubscribeConsumer?.(unsubscribeCallback); 27 | return unsubscribeCallback; 28 | } 29 | 30 | /** 31 | * Unsubscribes a listener from this event emitter 32 | */ 33 | public unsubscribe(listener: EventEmitterListener) { 34 | const index = this.listeners.indexOf(listener, 0); 35 | if(index !== -1) this.listeners.splice(index, 1); 36 | } 37 | 38 | /** 39 | * Notifies all registered listeners of a new event 40 | */ 41 | public notify(event: T) { 42 | for(const listener of this.listeners) listener(event); 43 | } 44 | } 45 | 46 | /** 47 | * An {@link EventEmitter} that automatically emits the last 48 | * item on subscribe 49 | */ 50 | export class CachedEventEmitter extends EventEmitter { 51 | private lastEvent: T | null = null; 52 | 53 | constructor(lastEvent: T | null = null) { 54 | super(); 55 | this.lastEvent = lastEvent; 56 | } 57 | 58 | public override subscribe(listener: EventEmitterListener): UnsubscribeCallback { 59 | super.subscribe(listener); 60 | if(this.lastEvent !== null) { 61 | listener(this.lastEvent); 62 | } 63 | return () => this.unsubscribe(listener); 64 | } 65 | 66 | public override notify(event: T) { 67 | super.notify(event); 68 | this.lastEvent = event; 69 | } 70 | } -------------------------------------------------------------------------------- /src/util/hashUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generates a hashcode from a string 3 | */ 4 | //https://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/ 5 | export function hashString(input: string): number { 6 | let hash = 0; 7 | for(let i = 0; i < input.length; i++) { 8 | const chr = input.charCodeAt(i); 9 | hash = ((hash << 5) - hash) + chr; 10 | hash |= 0; // Convert to 32bit integer 11 | } 12 | 13 | return hash; 14 | } -------------------------------------------------------------------------------- /src/util/installationUtils.ts: -------------------------------------------------------------------------------- 1 | import {v4 as uuidv4} from "uuid"; 2 | 3 | export enum StorageKey { 4 | InstallationID = "installationID" 5 | } 6 | 7 | /** 8 | * Gets the installation ID of this instance 9 | */ 10 | export function getInstallationID(): string { 11 | const installationID = localStorage.getItem(StorageKey.InstallationID); 12 | //Just return the installation ID value if we already have one 13 | if(installationID) { 14 | return installationID; 15 | } else { 16 | //Generating a new installation ID 17 | const installationID = uuidv4(); 18 | 19 | //Saving the installation ID to local storage 20 | localStorage.setItem(StorageKey.InstallationID, installationID); 21 | 22 | //Returning the installation ID 23 | return installationID; 24 | } 25 | } -------------------------------------------------------------------------------- /src/util/messageFlow.ts: -------------------------------------------------------------------------------- 1 | import PaletteSpecifier from "shared/data/paletteSpecifier"; 2 | 3 | /** 4 | * A message's position in the thread in accordance with other nearby messages 5 | */ 6 | export interface MessageFlow { 7 | //Whether this message should be anchored to the message above 8 | anchorTop: boolean; 9 | 10 | //Whether this message should be anchored to the message below 11 | anchorBottom: boolean; 12 | 13 | //Whether this message should have a divider between it and the message below 14 | showDivider: boolean; 15 | } 16 | 17 | export interface MessagePartFlow { 18 | //Whether this message is outgoing 19 | isOutgoing: boolean; 20 | 21 | //Whether this message is unconfirmed, and should be rendered as such 22 | isUnconfirmed: boolean; 23 | 24 | color: PaletteSpecifier; //Text and action button colors 25 | backgroundColor: PaletteSpecifier; //Message background color 26 | 27 | //Whether this message should be anchored to the message above 28 | anchorTop: boolean; 29 | 30 | //Whether this message should be anchored to the message below 31 | anchorBottom: boolean; 32 | } 33 | 34 | const radiusLinked = "4px"; 35 | const radiusUnlinked = "16.5px"; 36 | 37 | /** 38 | * Generates a CSS border radius string from the provided flow 39 | */ 40 | export function getFlowBorderRadius(flow: MessagePartFlow): string { 41 | const radiusTop = flow.anchorTop ? radiusLinked : radiusUnlinked; 42 | const radiusBottom = flow.anchorBottom ? radiusLinked : radiusUnlinked; 43 | 44 | if(flow.isOutgoing) { 45 | return `${radiusUnlinked} ${radiusTop} ${radiusBottom} ${radiusUnlinked}`; 46 | } else { 47 | return `${radiusTop} ${radiusUnlinked} ${radiusUnlinked} ${radiusBottom}`; 48 | } 49 | } 50 | 51 | const opacityUnconfirmed = 0.5; 52 | 53 | /** 54 | * Generates a CSS opacity radius value from the provided flow 55 | */ 56 | export function getFlowOpacity(flow: MessagePartFlow): number | undefined { 57 | if(flow.isUnconfirmed) { 58 | return opacityUnconfirmed; 59 | } else { 60 | return undefined; 61 | } 62 | } 63 | 64 | const spacingLinked = 0.25; 65 | const spacingUnlinked = 1; 66 | 67 | /** 68 | * Gets the spacing value to use between message bubbles 69 | * @param linked Whether the message is linked to the adjacent message 70 | */ 71 | export function getBubbleSpacing(linked: boolean): number { 72 | if(linked) { 73 | return spacingLinked; 74 | } else { 75 | return spacingUnlinked; 76 | } 77 | } -------------------------------------------------------------------------------- /src/util/promiseTimeout.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wraps a promise, and returns a new promise that rejects after the 3 | * specified amount of time 4 | * @param timeout The promise timeout in milliseconds 5 | * @param timeoutReason The reason to use when rejecting the promise 6 | * @param promise The promise to wrap 7 | */ 8 | export default function promiseTimeout(timeout: number, timeoutReason: any | undefined, promise: Promise): Promise { 9 | // Create a promise that rejects in milliseconds 10 | const timeoutPromise = new Promise((resolve, reject) => { 11 | const id = setTimeout(() => { 12 | clearTimeout(id); 13 | reject(timeoutReason); 14 | }, timeout); 15 | }); 16 | 17 | return Promise.race([promise, timeoutPromise]); 18 | } -------------------------------------------------------------------------------- /src/util/resolveablePromise.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A promise wrapper that can be resolved or rejected from outside the object 3 | */ 4 | export default class ResolveablePromise { 5 | public readonly promise: Promise; 6 | private promiseResolve!: (value: T | PromiseLike) => void; 7 | private promiseReject!: (reason?: any) => void; 8 | 9 | constructor() { 10 | this.promise = new Promise((resolve, reject) => { 11 | this.promiseResolve = resolve; 12 | this.promiseReject = reject; 13 | }); 14 | } 15 | 16 | resolve(value: T | PromiseLike): void { 17 | this.promiseResolve(value); 18 | } 19 | 20 | reject(reason?: any): void { 21 | this.promiseReject(reason); 22 | } 23 | } -------------------------------------------------------------------------------- /src/util/resolveablePromiseTimeout.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A promise wrapper that can be resolved or rejected from outside the object 3 | */ 4 | import ResolveablePromise from "shared/util/resolveablePromise"; 5 | 6 | /** 7 | * A ResolveablePromise that can also be set to time out with a specified error 8 | */ 9 | export default class ResolveablePromiseTimeout extends ResolveablePromise { 10 | private timeoutID: any | undefined = undefined; 11 | 12 | /** 13 | * Sets this promise to time out after duration with reason 14 | */ 15 | timeout(duration: number, reason?: any): void { 16 | //Clear any existing timeouts 17 | if(this.timeoutID !== undefined) { 18 | clearTimeout(this.timeoutID); 19 | } 20 | 21 | //Set the timeout 22 | this.timeoutID = setTimeout(() => { 23 | this.reject(reason); 24 | }, duration); 25 | } 26 | 27 | /** 28 | * Clears the current timeout on this promise 29 | */ 30 | clearTimeout() { 31 | //Ignore if there is no timeout 32 | if(this.timeoutID === undefined) return; 33 | 34 | //Cancel the timeout 35 | clearTimeout(this.timeoutID); 36 | this.timeoutID = undefined; 37 | } 38 | 39 | resolve(value: PromiseLike | T) { 40 | this.clearTimeout(); 41 | super.resolve(value); 42 | } 43 | 44 | reject(reason?: any) { 45 | this.clearTimeout(); 46 | super.reject(reason); 47 | } 48 | } -------------------------------------------------------------------------------- /src/util/secureStorageUtils.ts: -------------------------------------------------------------------------------- 1 | import * as secrets from "../secrets"; 2 | import {decodeBase64, encodeBase64} from "shared/util/encodingUtils"; 3 | 4 | const ivLen = 12; 5 | 6 | export enum SecureStorageKey { 7 | ServerPassword = "serverPassword", 8 | GoogleRefreshToken = "googleRefreshToken" 9 | } 10 | 11 | const cryptoKey: Promise = crypto.subtle.importKey( 12 | "jwk", 13 | secrets.jwkLocalEncryption, 14 | "AES-GCM", 15 | false, 16 | ["encrypt", "decrypt"] 17 | ); 18 | 19 | function concatBuffers(buffer1: ArrayBuffer, buffer2: ArrayBuffer): ArrayBuffer { 20 | const tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength); 21 | tmp.set(new Uint8Array(buffer1), 0); 22 | tmp.set(new Uint8Array(buffer2), buffer1.byteLength); 23 | return tmp; 24 | } 25 | 26 | async function encrypt(inData: ArrayBuffer, generateIV: boolean): Promise { 27 | if(generateIV) { 28 | const iv = window.crypto.getRandomValues(new Uint8Array(ivLen)); 29 | const encrypted = await crypto.subtle.encrypt({name: "AES-GCM", iv: iv}, await cryptoKey, inData); 30 | return concatBuffers(iv, encrypted); 31 | } else { 32 | return crypto.subtle.encrypt({name: "AES-GCM", iv: new Uint8Array(ivLen)}, await cryptoKey, inData); 33 | } 34 | } 35 | 36 | async function decrypt(inData: ArrayBuffer, useIV: boolean): Promise { 37 | if(useIV) { 38 | const iv = inData.slice(0, ivLen); 39 | const data = inData.slice(ivLen); 40 | return crypto.subtle.decrypt({name: "AES-GCM", iv: iv}, await cryptoKey, data); 41 | } else { 42 | return crypto.subtle.decrypt({name: "AES-GCM", iv: new Int8Array(ivLen)}, await cryptoKey, inData); 43 | } 44 | } 45 | 46 | /** 47 | * Encrypts a string and returns it in base64 form 48 | */ 49 | async function encryptString(value: string, generateIV: boolean): Promise { 50 | return encodeBase64(await encrypt(new TextEncoder().encode(value), generateIV)); 51 | } 52 | 53 | /** 54 | * Decrypts a string from its base64 form 55 | */ 56 | async function decryptString(value: string, useIV: boolean): Promise { 57 | return new TextDecoder().decode(await decrypt(decodeBase64(value), useIV)); 58 | } 59 | 60 | /** 61 | * Stores a value in secure storage 62 | * @param key The storage key to use 63 | * @param value The value to use, or undefined to remove 64 | */ 65 | export async function setSecureLS(key: SecureStorageKey, value: string | undefined) { 66 | const encryptedKey = await encryptString(key, false); 67 | 68 | if(value === undefined) { 69 | localStorage.removeItem(encryptedKey); 70 | } else { 71 | value = await encryptString(value, true); 72 | localStorage.setItem(encryptedKey, value); 73 | } 74 | } 75 | 76 | /** 77 | * Reads a value from secure storage 78 | * @param key The storage key to read from 79 | */ 80 | export async function getSecureLS(key: SecureStorageKey): Promise { 81 | const value = localStorage.getItem(await encryptString(key, false)); 82 | if(value === null) { 83 | return undefined; 84 | } else { 85 | return decryptString(value, true); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/util/soundUtils.ts: -------------------------------------------------------------------------------- 1 | import soundNotification from "shared/resources/audio/notification.wav"; 2 | import soundMessageIn from "shared/resources/audio/message_in.wav"; 3 | import soundMessageOut from "shared/resources/audio/message_out.wav"; 4 | import soundTapback from "shared/resources/audio/tapback.wav"; 5 | 6 | /** 7 | * Plays the audio sound for an incoming notification 8 | */ 9 | export function playSoundNotification() { 10 | new Audio(soundNotification).play()?.catch((reason) => { 11 | console.log("Failed to play notification audio: " + reason); 12 | }); 13 | } 14 | 15 | /** 16 | * Plays the audio sound for an incoming message 17 | */ 18 | export function playSoundMessageIn() { 19 | new Audio(soundMessageIn).play()?.catch((reason) => { 20 | console.log("Failed to play incoming message audio: " + reason); 21 | }); 22 | } 23 | 24 | /** 25 | * Plays the audio sound for an outgoing message 26 | */ 27 | export function playSoundMessageOut() { 28 | new Audio(soundMessageOut).play()?.catch((reason) => { 29 | console.log("Failed to play outgoing message audio: " + reason); 30 | }); 31 | } 32 | 33 | /** 34 | * Plays the audio sound for a new tapback 35 | */ 36 | export function playSoundTapback() { 37 | new Audio(soundTapback).play()?.catch((reason) => { 38 | console.log("Failed to play tapback audio: " + reason); 39 | }); 40 | } -------------------------------------------------------------------------------- /src/util/taskQueue.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * TaskQueue enqueues promises, ensuring that all promises 3 | * complete in the order they were enqueued in 4 | */ 5 | export default class TaskQueue { 6 | private previousTask: Promise | undefined; 7 | 8 | /** 9 | * Enqueues a new promise. 10 | * The generator is only called when the previous promise in the queue completes. 11 | * The returned promise completes when itself and all of the promises before it have completed. 12 | */ 13 | public enqueue(value: () => Promise): void { 14 | if(this.previousTask === undefined) { 15 | this.previousTask = value(); 16 | } else { 17 | this.previousTask = this.previousTask.then(value); 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /src/util/versionUtils.ts: -------------------------------------------------------------------------------- 1 | /* Compares 2 version lists and returns which one is larger 2 | -1: version 1 is smaller 3 | 0: versions are equal 4 | 1: version 1 is greater 5 | */ 6 | export function compareVersions(version1: number[], version2: number[]): number { 7 | for(let i = 0; i < Math.max(version1.length, version2.length); i++) { 8 | //Get the version codes, defaulting to 0 if the length exceeds the code 9 | const code1 = version1[i] ?? 0; 10 | const code2 = version2[i] ?? 0; 11 | 12 | //Compare the codes 13 | const comparison = code1 - code2; 14 | 15 | if(comparison != 0) { 16 | return comparison; 17 | } 18 | } 19 | 20 | //All version codes are the same, no difference 21 | return 0; 22 | } -------------------------------------------------------------------------------- /test/util/arrayUtils.test.ts: -------------------------------------------------------------------------------- 1 | import {arrayContainsAll, groupArray} from "../../src/util/arrayUtils"; 2 | 3 | type ValueHolder = {value: T}; 4 | type KeyValueHolder = {key: K, value: V}; 5 | 6 | function compareTypes(a: unknown, b: unknown): number { 7 | const typeA = typeof a; 8 | const typeB = typeof b; 9 | 10 | if(typeA < typeB) return -1; 11 | else if(typeA > typeB) return 1; 12 | else return 0; 13 | } 14 | 15 | describe("arrayContainsAll", () => { 16 | test("empty arrays should match", () => { 17 | const array: unknown[] = []; 18 | expect(arrayContainsAll(array, array)).toBe(true); 19 | }); 20 | 21 | test("matching arrays should match", () => { 22 | const array = [0, 1, "string", undefined, null]; 23 | expect(arrayContainsAll(array, array)).toBe(true); 24 | }); 25 | 26 | test("matching arrays in different order should match", () => { 27 | const array1 = [0, 1, "string", undefined, null]; 28 | const array2 = [...array1].reverse(); 29 | expect(arrayContainsAll(array1, array2)).toBe(true); 30 | }); 31 | 32 | test("matching arrays with a custom mapper should match", () => { 33 | const array1 = [0, 1, "string", undefined, null].map((it): ValueHolder => ({value: it})); 34 | const array2 = [...array1].reverse(); 35 | expect(arrayContainsAll(array1, array2, (it) => it.value)).toBe(true); 36 | }); 37 | 38 | test("matching arrays with a custom matcher should match", () => { 39 | const array1 = [0, "1", 2]; 40 | const array2 = ["3", 4, 5]; 41 | expect(arrayContainsAll(array1, array2, undefined, compareTypes)).toBe(true); 42 | }); 43 | 44 | test("arrays of differing lengths should not match", () => { 45 | const array1 = [0, 1, "string", undefined, null]; 46 | const array2 = [0, 1, "string", undefined, null, null]; 47 | expect(arrayContainsAll(array1, array2)).toBe(false); 48 | }); 49 | 50 | test("arrays of differing values should not match", () => { 51 | const array1 = [0, 1, "string", undefined, null]; 52 | const array2 = [0, 1, 2, undefined, null]; 53 | expect(arrayContainsAll(array1, array2)).toBe(false); 54 | }); 55 | 56 | test("arrays of differing values with a custom mapper should not match", () => { 57 | const array1 = [0, 1, "string", undefined, null].map((it): ValueHolder => ({value: it})); 58 | const array2 = [0, 1, 2, undefined, null].map((it): ValueHolder => ({value: it})); 59 | expect(arrayContainsAll(array1, array2, (it) => it.value)).toBe(false); 60 | }); 61 | 62 | test("non-matching arrays with a custom matcher should not match", () => { 63 | const array1 = [0, 1, 2]; 64 | const array2 = ["3", 4, 5]; 65 | expect(arrayContainsAll(array1, array2, undefined, compareTypes)).toBe(false); 66 | }); 67 | }); 68 | 69 | describe("groupArray", () => { 70 | test("an empty array should produce an empty map", () => { 71 | expect(groupArray([], () => undefined).size).toBe(0); 72 | }); 73 | 74 | test("a map with proper keys should be produced", () => { 75 | const redThings = ["apple", "lava", "ruby"].map((it): KeyValueHolder => ({key: "red", value: it})); 76 | const greenThings = ["grass", "leaf"].map((it): KeyValueHolder => ({key: "green", value: it})); 77 | const blueThings = ["water"].map((it): KeyValueHolder => ({key: "blue", value: it})); 78 | 79 | const array = [...redThings, ...greenThings, ...blueThings]; 80 | 81 | const groupedMap = groupArray(array, (it) => it.key); 82 | 83 | expect(groupedMap.get("red")).toEqual(expect.arrayContaining(redThings)); 84 | expect(groupedMap.get("green")).toEqual(expect.arrayContaining(greenThings)); 85 | expect(groupedMap.get("blue")).toEqual(expect.arrayContaining(blueThings)); 86 | }); 87 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "outDir": "dist", 5 | "target": "ES2021", 6 | "lib": [ 7 | "es5", 8 | "es6", 9 | "es2017", 10 | "es2018", 11 | "es2019", 12 | "es2020", 13 | "es2021", 14 | "dom", 15 | "dom.iterable" 16 | ], 17 | "skipLibCheck": true, 18 | "esModuleInterop": true, 19 | "allowSyntheticDefaultImports": true, 20 | "strict": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "moduleResolution": "node", 23 | "resolveJsonModule": true, 24 | "isolatedModules": false, 25 | "noImplicitThis": true, 26 | "noImplicitAny": true, 27 | "strictNullChecks": true, 28 | "jsx": "react", 29 | "noFallthroughCasesInSwitch": true, 30 | "paths": { 31 | "shared/*": ["./src/*"], 32 | "platform-components/*": ["./browser/*"] 33 | }, 34 | "sourceMap": true, 35 | "inlineSources": true 36 | }, 37 | "include": [ 38 | "src", 39 | "browser", 40 | "windows/web", 41 | "index.d.ts", 42 | "window.ts" 43 | ] 44 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | const webpack = require("webpack"); 3 | const path = require("path"); 4 | const fs = require("fs"); 5 | const ForkTsCheckerWebpackPlugin = require("fork-ts-checker-webpack-plugin"); 6 | // const ESLintPlugin = require("eslint-webpack-plugin"); 7 | const CopyPlugin = require("copy-webpack-plugin"); 8 | const WorkboxPlugin = require("workbox-webpack-plugin"); 9 | 10 | module.exports = (env) => ({ 11 | entry: "./src/index.tsx", 12 | target: "web", 13 | mode: env.WEBPACK_SERVE ? "development" : "production", 14 | devtool: env.WEBPACK_SERVE ? "cheap-source-map" : "source-map", 15 | devServer: { 16 | static: { 17 | directory: path.join(__dirname, "public") 18 | }, 19 | port: 8080, 20 | https: env.secure ? { 21 | key: fs.readFileSync("webpack.key"), 22 | cert: fs.readFileSync("webpack.crt"), 23 | } : undefined 24 | }, 25 | output: { 26 | path: path.resolve(__dirname, "build"), 27 | filename: "index.js", 28 | assetModuleFilename: "res/[hash][ext][query]", 29 | publicPath: "", 30 | clean: true 31 | }, 32 | module: { 33 | rules: [ 34 | { 35 | test: /\.ts(x)?$/, 36 | loader: "ts-loader", 37 | exclude: /node_modules/, 38 | options: { 39 | transpileOnly: true 40 | } 41 | }, 42 | { 43 | enforce: "pre", 44 | test: /\.js$/, 45 | loader: "source-map-loader" 46 | }, 47 | { 48 | test: /\.css$/, 49 | use: [ 50 | "style-loader", 51 | "css-loader" 52 | ], 53 | exclude: /\.module\.css$/ 54 | }, 55 | { 56 | test: /\.css$/, 57 | use: [ 58 | "style-loader", 59 | { 60 | loader: "css-loader", 61 | options: { 62 | importLoaders: 1, 63 | modules: true 64 | } 65 | } 66 | ], 67 | include: /\.module\.css$/ 68 | }, 69 | { 70 | test: /\.(svg)|(wav)$/, 71 | type: "asset/resource" 72 | }, 73 | { 74 | test: /\.md$/, 75 | type: "asset/source" 76 | } 77 | ] 78 | }, 79 | resolve: { 80 | extensions: [ 81 | ".tsx", 82 | ".ts", 83 | ".js" 84 | ], 85 | alias: { 86 | "shared": path.resolve(__dirname, "src") 87 | } 88 | }, 89 | optimization: { 90 | usedExports: true 91 | }, 92 | plugins: [ 93 | new ForkTsCheckerWebpackPlugin(), 94 | /* new ESLintPlugin({ 95 | files: ["src", "browser", "electron-main", "electron-renderer"], 96 | extensions: ["js", "jsx", "ts", "tsx"] 97 | }), */ 98 | new CopyPlugin({ 99 | patterns: [ 100 | {from: "public"} 101 | ] 102 | }), 103 | new webpack.DefinePlugin({ 104 | "WPEnv.ENVIRONMENT": JSON.stringify(env.WEBPACK_SERVE ? "development" : "production"), 105 | "WPEnv.PACKAGE_VERSION": JSON.stringify(process.env.npm_package_version), 106 | "WPEnv.RELEASE_HASH": "\"undefined\"", 107 | "WPEnv.BUILD_DATE": Date.now() 108 | }), 109 | ].concat(!env.WEBPACK_SERVE ? new WorkboxPlugin.GenerateSW() : []) 110 | }); --------------------------------------------------------------------------------