├── .editorconfig ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ └── tests.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── build ├── background.png ├── background@2x.png ├── entitlements.mac.plist ├── icon.icns ├── icon.png └── icons │ ├── 128x128.png │ ├── 16x16.png │ ├── 256x256.png │ ├── 32x32.png │ ├── 48x48.png │ ├── 512x512.png │ └── 64x64.png ├── css ├── autoplay.css ├── browser.css ├── code-blocks.css ├── dark-mode.css ├── scrollbar.css ├── vibrancy.css └── workchat.css ├── license ├── media ├── AppIcon-readme.png ├── AppIcon.pdf ├── AppIcon.png ├── AppIcon.sketch ├── screenshot-block-typing-indicator.png ├── screenshot-codeblocks-dark.png ├── screenshot-codeblocks-light.png ├── screenshot-compact.png ├── screenshot-dark.png ├── screenshot-dock-menu.png ├── screenshot-hide-notification-message-after.png ├── screenshot-hide-notification-message-before.png ├── screenshot-hide-notification-message-location.png ├── screenshot-menu-bar-menu.png ├── screenshot-menu-bar-mode.png ├── screenshot-notification.png ├── screenshot-touchbar.png ├── screenshot-vibrancy.jpg ├── screenshot-work-chat.png └── screenshot.png ├── package-lock.json ├── package.json ├── packages ├── deb │ └── addRepo.sh └── rpm │ ├── caprine.desktop │ └── caprine.spec ├── patches ├── electron-debug++electron-is-dev+1.2.0.patch └── electron-util++electron-is-dev+1.2.0.patch ├── readme.md ├── source ├── autoplay.ts ├── browser-call.ts ├── browser.ts ├── browser │ ├── conversation-list.ts │ └── selectors.ts ├── config.ts ├── constants.ts ├── conversation.d.ts ├── do-not-disturb.d.ts ├── emoji.ts ├── ensure-online.ts ├── index.ts ├── menu-bar-mode.ts ├── menu.ts ├── notification-event.d.ts ├── notifications-isolated.ts ├── notifications.d.ts ├── spell-checker.ts ├── touch-bar.ts ├── tray.ts ├── types.ts └── util.ts ├── static ├── Icon.png ├── IconMenuBarTemplate.png ├── IconMenuBarTemplate@2x.png ├── IconMenuBarUnreadTemplate.png ├── IconMenuBarUnreadTemplate@2x.png ├── IconTray.png ├── IconTray@2x.png ├── IconTrayUnread.png ├── IconTrayUnread@2x.png ├── emoji-facebook-2-2.png ├── emoji-facebook-2-2@2x.png ├── emoji-facebook-3-0.png ├── emoji-facebook-3-0@2x.png ├── emoji-messenger-1-0.png ├── emoji-messenger-1-0@2x.png └── readme.md └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for more information: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | # https://containers.dev/guide/dependabot 6 | 7 | version: 2 8 | updates: 9 | - package-ecosystem: "devcontainers" 10 | directory: "/" 11 | schedule: 12 | interval: weekly 13 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | jobs: 7 | tests: 8 | uses: ./.github/workflows/tests.yml 9 | build: 10 | needs: [tests] 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | os: 15 | - macos-latest 16 | - ubuntu-latest 17 | - windows-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Setup Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: 20 24 | cache: 'npm' 25 | - name: Install dependencies 26 | run: npm ci 27 | - name: Build Caprine 28 | run: npm run build 29 | - name: Cleanup tag 30 | uses: mad9000/actions-find-and-replace-string@5 31 | id: release_tag 32 | with: 33 | source: ${{ github.ref_name }} 34 | find: v 35 | replace: '' 36 | - name: Install Snapcraft 37 | uses: samuelmeuli/action-snapcraft@v1 38 | if: startsWith(matrix.os, 'ubuntu') 39 | - name: Package Caprine for macOS 40 | if: startsWith(matrix.os, 'macos') 41 | run: npm run dist:mac 42 | env: 43 | CSC_LINK: ${{ secrets.CSC_LINK }} 44 | CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} 45 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | - name: Package Caprine for Windows 47 | if: startsWith(matrix.os, 'windows') 48 | run: npm run dist:win 49 | env: 50 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | - name: Package Caprine for Linux 52 | if: startsWith(matrix.os, 'ubuntu') 53 | run: npm run dist:linux 54 | env: 55 | SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }} 56 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | - name: Upload to Gemfury 58 | if: startsWith(matrix.os, 'ubuntu') 59 | run: curl -F package=@dist/caprine_${{ steps.release_tag.outputs.value }}_amd64.deb https://${{ secrets.gemfury_token }}@push.fury.io/lefterisgar/ 60 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - gh-pages 7 | pull_request: 8 | workflow_call: 9 | 10 | jobs: 11 | npm-cache: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: 20 19 | cache: 'npm' 20 | - name: Install dependencies 21 | run: npm ci 22 | tsc: 23 | runs-on: ubuntu-latest 24 | needs: npm-cache 25 | steps: 26 | - uses: actions/checkout@v4 27 | - name: Setup Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: 20 31 | cache: 'npm' 32 | - name: Compile TypeScript 33 | run: | 34 | npm ci 35 | npm run test:tsc 36 | xo: 37 | runs-on: ubuntu-latest 38 | needs: npm-cache 39 | steps: 40 | - uses: actions/checkout@v4 41 | - name: Setup Node.js 42 | uses: actions/setup-node@v4 43 | with: 44 | node-version: 20 45 | cache: 'npm' 46 | - name: Lint source code 47 | run: | 48 | npm ci 49 | npm run lint:xo 50 | stylelint: 51 | runs-on: ubuntu-latest 52 | needs: npm-cache 53 | steps: 54 | - uses: actions/checkout@v4 55 | - name: Setup Node.js 56 | uses: actions/setup-node@v4 57 | with: 58 | node-version: 20 59 | cache: 'npm' 60 | - name: Lint styles 61 | run: | 62 | npm ci 63 | npm run lint:stylelint 64 | rpmspec: 65 | runs-on: ubuntu-latest 66 | steps: 67 | - uses: actions/checkout@v4 68 | - name: Lint rpm spec file 69 | uses: EyeCantCU/rpmlint-action@v0.1.1 70 | with: 71 | rpmfiles: packages/rpm/caprine.spec 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | /dist 4 | /dist-js 5 | mas.provisionprofile 6 | .vscode 7 | .DS_store 8 | /.devcontainer 9 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/hydrogen 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.md 2 | -------------------------------------------------------------------------------- /build/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/build/background.png -------------------------------------------------------------------------------- /build/background@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/build/background@2x.png -------------------------------------------------------------------------------- /build/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | com.apple.security.cs.allow-dyld-environment-variables 10 | 11 | com.apple.security.device.camera 12 | 13 | com.apple.security.device.audio-input 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/build/icon.icns -------------------------------------------------------------------------------- /build/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/build/icon.png -------------------------------------------------------------------------------- /build/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/build/icons/128x128.png -------------------------------------------------------------------------------- /build/icons/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/build/icons/16x16.png -------------------------------------------------------------------------------- /build/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/build/icons/256x256.png -------------------------------------------------------------------------------- /build/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/build/icons/32x32.png -------------------------------------------------------------------------------- /build/icons/48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/build/icons/48x48.png -------------------------------------------------------------------------------- /build/icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/build/icons/512x512.png -------------------------------------------------------------------------------- /build/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/build/icons/64x64.png -------------------------------------------------------------------------------- /css/autoplay.css: -------------------------------------------------------------------------------- 1 | .disabledAutoPlayImgTopRadius { 2 | border-top-left-radius: 1.3em; 3 | border-top-right-radius: 1.3em; 4 | } 5 | 6 | .disabledAutoPlayImgBottomRadius { 7 | border-bottom-left-radius: 1.3em; 8 | border-bottom-right-radius: 1.3em; 9 | } 10 | -------------------------------------------------------------------------------- /css/browser.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --selected-conversation-background: linear-gradient(hsla(209deg 110% 45% / 90%), hsla(209deg 110% 42% / 90%)); 3 | --selected-conversation-background-inactive: #d2d2d2; 4 | --black: #000; 5 | } 6 | 7 | html { 8 | overflow: hidden; 9 | } 10 | 11 | /* Add OS-specific fonts */ 12 | body { 13 | font-family: 14 | -apple-system, 15 | BlinkMacSystemFont, 16 | 'Segoe UI', 17 | Roboto, 18 | Oxygen-Sans, 19 | Ubuntu, 20 | Cantarell, 21 | 'Helvetica Neue', 22 | sans-serif, 23 | 'Apple Color Emoji', 24 | 'Segoe UI Emoji', 25 | 'Segoe UI Symbol' !important; 26 | text-rendering: optimizelegibility !important; 27 | font-feature-settings: 'liga', 'clig', 'kern'; 28 | } 29 | 30 | /* Bind the toolbar as the window's draggable region */ 31 | /* Bar above conversation list */ 32 | [role='navigation'] .x78zum5.xdt5ytf.xzd29fr { 33 | -webkit-app-region: drag; 34 | } 35 | /* New message button */ 36 | [role='navigation'] .x16n37ib { 37 | -webkit-app-region: no-drag; 38 | } 39 | /* Bar above chat */ 40 | [role='main'] .x1u998qt.x1vjfegm { 41 | -webkit-app-region: drag; 42 | } 43 | /* Conversation name */ 44 | [role='main'] .x1i10hfl.x1qjc9v5.xjbqb8w.xjqpnuy.xa49m3k.xqeqjp1.x2hbi6w.x13fuv20.xu3j5b3.x1q0q8m5.x26u7qi.x972fbf.xcfux6l.x1qhh985.xm0m39n.x9f619.x1ypdohk.xdl72j9.x2lah0s.xe8uvvx.xdj266r.x11i5rnm.xat24cr.x1mh8g0r.x2lwn1j.xeuugli.xexx8yu.x4uap5.x18d9i69.xkhd6sd.x1n2onr6.x16tdsg8.x1hl2dhg.xggy1nq.x1ja2u2z.x1t137rt.x1o1ewxj.x3x9cwd.x1e5q0jg.x13rtm0m.x1q0g3np.x87ps6o.x1lku1pv.x1a2a7pz.x78zum5 { 45 | -webkit-app-region: no-drag; 46 | } 47 | /* Top right buttons */ 48 | [role='main'] .x9f619.x1n2onr6.x1ja2u2z.x78zum5.x2lah0s.x1qughib.x6s0dn4.xozqiw3.x1q0g3np.xykv574.xbmpl8g.x4cne27.xifccgj { 49 | -webkit-app-region: no-drag; 50 | } 51 | /* View label above conversation list margin */ 52 | .os-darwin [role='navigation'] .x1heor9g.x1qlqyl8.x1pd3egz.x1a2a7pz { 53 | margin-left: 60px; 54 | } 55 | 56 | /* Hide footer at login view */ 57 | ._210n { 58 | display: none; 59 | } 60 | 61 | /* Don't show outline on clickable elements & input fields */ 62 | *[role='button'], 63 | *[type='text'], 64 | *[type='password'] { 65 | outline: none !important; 66 | } 67 | 68 | [role='navigation'] a { 69 | cursor: default !important; 70 | } 71 | 72 | /* Remove top Facebook cookie banner */ 73 | .fbPageBanner { 74 | display: none !important; 75 | } 76 | 77 | /* Cookies notification: Adjust size for smaller windows */ 78 | ._9o-g { 79 | height: 290px !important; 80 | } 81 | ._9xo5 { 82 | padding-top: 12px !important; 83 | } 84 | ._59s7._9l2g { 85 | height: 498px !important; 86 | } 87 | 88 | /* Cookies notification: Remove "allow all cookies" button */ 89 | ._42ft._4jy0._9xo7._4jy3._4jy1.selected._51sy { 90 | display: none; 91 | } 92 | 93 | /* Cookies notification: accept button */ 94 | ._42ft._4jy0._9xo6._4jy3._4jy1.selected._51sy { 95 | background-color: #1877f2 !important; 96 | color: var(--white) !important; 97 | } 98 | 99 | /* Hide disabled scrollbar on the right side */ 100 | body::-webkit-scrollbar { 101 | display: none; 102 | } 103 | 104 | /* A utility class for temporarily hiding all dropdown menus */ 105 | html.hide-dropdowns [role='menu'].x1n2onr6.xi5betq { 106 | visibility: hidden !important; 107 | } 108 | 109 | /* A utility class for temporarily hiding preferences window */ 110 | html.hide-preferences-window div[class='x9f619 x1n2onr6 x1ja2u2z'] > div:nth-of-type(3) > div > div { 111 | display: none; 112 | } 113 | 114 | /* -- Private mode -- */ 115 | /* Preferences button: profile picture */ 116 | html.private-mode [role='navigation'] .qi72231t.o9w3sbdw.nu7423ey.tav9wjvu.flwp5yud.tghlliq5.gkg15gwv.s9ok87oh.s9ljgwtm.lxqftegz.bf1zulr9.frfouenu.bonavkto.djs4p424.r7bn319e.bdao358l.fsf7x5fv.tgm57n0e.jez8cy9q.s5oniofx.m8h3af8h.l7ghb35v.kjdc1dyq.kmwttqpk.dnr7xe2t.aeinzg81.srn514ro.oxkhqvkx.rl78xhln.nch0832m.om3e55n1.cr00lzj9.rn8ck1ys.s3jn8y49.g4tp4svg.o9erhkwx.dzqi5evh.hupbnkgi.hvb2xoa8.fxk3tzhb.jl2a5g8c.f14ij5to.l3ldwz01.icdlwmnq { 117 | filter: blur(5px); 118 | } 119 | /* Preferences: profile picture */ 120 | html.private-mode .alzwoclg.b0eko5f3.q46jt4gp.r5g9zsuq .aglvbi8b.om3e55n1.i8zpp7h3.g4tp4svg { 121 | filter: blur(5px); 122 | } 123 | /* Preferences: account name */ 124 | html.private-mode [href^='https://www.facebook.com/1'] .b6ax4al1.i54nktwv.z2vv26z9.om3e55n1.gvxzyvdx.aeinzg81.t7p7dqev.gh25dzvf.gem102v4.ncib64c9.mrvwc6qr.sx8pxkcf.f597kf1v.cpcgwwas.ocv3nf92.k1z55t6l.tpi2lg9u.pbevjfx6.ztn2w49o.f5mw3jnl.ib8x7mpr.qc5lal2y { 125 | filter: blur(5px); 126 | } 127 | /* Chat list: name, profile picture and last message */ 128 | html.private-mode [role='navigation'] [role='row'] .b6ax4al1.gvxzyvdx { 129 | filter: blur(5px); 130 | } 131 | /* Chat list: person tiny heads */ 132 | html.private-mode [role='row'] .aglvbi8b.om3e55n1.i8zpp7h3.g4tp4svg { 133 | filter: blur(3px); 134 | } 135 | /* Conversation: titlebar profile picture */ 136 | html.private-mode .b6ax4al1.gvxzyvdx.dgxim35p.p9wrh9lq { 137 | filter: blur(5px); 138 | } 139 | /* Conversation: sender profile picture */ 140 | html.private-mode .mfclru0v.p9wrh9lq.pytsy3co.aglvbi8b { 141 | filter: blur(5px); 142 | } 143 | /* Conversation: name & last seen */ 144 | html.private-mode [role='main'] .alzwoclg.cqf1kptm.hael596l.jcxyg2ei.cgu29s5g.dn6jqzda { 145 | filter: blur(5px); 146 | } 147 | /* Conversation: read indicator */ 148 | html.private-mode [role='main'] .iec8yc8l.b7mnygb8.dktd5soj.f14ij5to.qmqpeqxj.e7u6y3za.qwcclf47.nmlomj2f { 149 | filter: blur(5px); 150 | } 151 | /* Conversation: name & details at the beginning */ 152 | html.private-mode .h6ft4zvz.rj2hsocd.aesu6q9g.e4ay1f3w .hsphh064.pk1vzqw1.hxfwr5lz.qc5lal2y { 153 | filter: blur(5px); 154 | } 155 | /* Right sidebar: profile picture */ 156 | html.private-mode .aglvbi8b.om3e55n1.i8zpp7h3.g4tp4svg { 157 | filter: blur(5px); 158 | } 159 | /* Right sidebar: name */ 160 | html.private-mode [role='main'] .qi72231t.nu7423ey.n3hqoq4p.r86q59rh.b3qcqh3k.fq87ekyn.bdao358l.fsf7x5fv.rse6dlih.s5oniofx.m8h3af8h.l7ghb35v.kjdc1dyq.kmwttqpk.srn514ro.oxkhqvkx.rl78xhln.nch0832m.cr00lzj9.rn8ck1ys.s3jn8y49.icdlwmnq.jxuftiz4.cxfqmxzd { 161 | filter: blur(5px); 162 | } 163 | /* Right sidebar: active status */ 164 | html.private-mode [role='main'] .b6ax4al1.i54nktwv.z2vv26z9.om3e55n1.gvxzyvdx.aeinzg81.t7p7dqev.gh25dzvf.gem102v4.ncib64c9.mrvwc6qr.sx8pxkcf.f597kf1v.cpcgwwas.ocv3nf92.nfkogyam.gh55jysx.rtxb060y.hsphh064.pk1vzqw1.hxfwr5lz.qc5lal2y { 165 | filter: blur(5px); 166 | } 167 | /* New conversation: profile picture */ 168 | html.private-mode .mfclru0v.pytsy3co.qmqpeqxj.e7u6y3za.qwcclf47.nmlomj2f.i8zpp7h3.p9wrh9lq { 169 | filter: blur(5px); 170 | } 171 | /* New conversation: profile picture (groups) */ 172 | html.private-mode .qmqpeqxj.e7u6y3za.qwcclf47.nmlomj2f.s8sjc6am { 173 | filter: blur(5px); 174 | } 175 | /* Calls: incoming call dialog account name */ 176 | html.private-mode .b6ax4al1.i54nktwv.z2vv26z9.om3e55n1.gvxzyvdx.aeinzg81.t7p7dqev.gh25dzvf.gem102v4.ncib64c9.mrvwc6qr.sx8pxkcf.f597kf1v.cpcgwwas.ocv3nf92.qntmu8s7.o48pnaf2.pbevjfx6.hsphh064.m2nijcs8.pc9ouhwb.qc5lal2y, 177 | html.private-mode .gvxzyvdx.aeinzg81.t7p7dqev.gh25dzvf.rse6dlih.ocv3nf92.nfkogyam.innypi6y.rtxb060y.qc5lal2y { 178 | filter: blur(10px); 179 | } 180 | 181 | /* Force max-width on videos */ 182 | .ni8dbmo4.stjgntxs.g5ia77u1.ii04i59q.j83agx80.cbu4d94t.ll8tlv6m > span, 183 | .l9j0dhe7.km676qkl.cxmmr5t8.myj7ivm5.hcukyx3x, 184 | .opwvks06.hop1g133.linmgsc8.t63ysoy8.qutah8gn.ni8dbmo4.stjgntxs.ktxn16wu.jz9ahs1c.efwgsih4.e72ty7fz.qmr60zad.qlfml3jp.inkptoze { 185 | max-width: 100%; 186 | } 187 | 188 | /* Hide the "Messenger App for Mac/Windows" banner in chat list */ 189 | .x9f619.x1n2onr6.x1ja2u2z.x78zum5.x1r8uery.xs83m0k.xeuugli.x1qughib.x6s0dn4.xozqiw3.x1q0g3np.xknmibj.x1c4vz4f.xt55aet.xexx8yu.xc73u3c.x18d9i69.x5ib6vp.x1lku1pv.xzd29fr { 190 | display: none; 191 | } 192 | /* Hide the "Messenger for Mac/Windows" menu item and separator in Messenger settings */ 193 | .x4k7w5x.x1h91t0o.x1beo9mf.xaigb6o.x12ejxvf.x3igimt.xarpa2k.xedcshv.x1lytzrv.x1t2pt76.x7ja8zs.x1n2onr6.x1qrby5j.x1jfb8zj > div > div:last-of-type > a { 194 | display: none; 195 | } 196 | 197 | /* Dragable region for macOS */ 198 | .os-darwin .rq0escxv.l9j0dhe7.du4w35lb.j83agx80.pfnyh3mw.i1fnvgqd.bp9cbjyn.owycx6da.btwxx1t3.jei6r52m.wkznzc2l.n851cfcs.dhix69tm.ahb00how, 199 | .os-darwin .rq0escxv.l9j0dhe7.du4w35lb.j83agx80.pfnyh3mw.i1fnvgqd.bp9cbjyn.owycx6da.btwxx1t3.hv4rvrfc.dati1w0a.f10w8fjw.pybr56ya.b5q2rw42.lq239pai.mysgfdmx.hddg9phg { 200 | -webkit-app-region: drag !important; 201 | } 202 | .os-darwin .rq0escxv.l9j0dhe7.du4w35lb.j83agx80.pfnyh3mw.i1fnvgqd.bp9cbjyn.owycx6da.btwxx1t3.jei6r52m.wkznzc2l.n851cfcs.dhix69tm.ahb00how { 203 | margin: 0 !important; 204 | padding: 12px 16px 0 !important; 205 | } 206 | .os-darwin .rq0escxv.l9j0dhe7.du4w35lb.j83agx80.cbu4d94t.g5gj957u.d2edcug0.hpfvmrgz.kud993qy.buofh1pr { 207 | margin-top: 24px !important; 208 | } 209 | 210 | @media (max-width: 900px) { 211 | .os-darwin .rq0escxv.l9j0dhe7.du4w35lb.jbae33se.hv4rvrfc.dati1w0a.pybr56ya { 212 | display: none !important; 213 | } 214 | .os-darwin .rpm2j7zs.k7i0oixp.gvuykj2m.j83agx80.cbu4d94t.ni8dbmo4.du4w35lb.q5bimw55.ofs802cu.pohlnb88.dkue75c7.mb9wzai9.d8ncny3e.buofh1pr.g5gj957u.tgvbjcpo.l56l04vs.r57mb794.kh7kg01d.eg9m0zos.c3g1iek1.l9j0dhe7.k4xni2cv { 215 | padding-top: 36px !important; 216 | } 217 | } 218 | 219 | /* -- Sidebar views -- */ 220 | 221 | /* Hidden: Hide sidebar */ 222 | html.sidebar-hidden .bdao358l.om3e55n1.alzwoclg.cqf1kptm.gvxzyvdx.aeinzg81.jez8cy9q.fawcizw8.sl4bvocy.mm98tyaj.b0ur3jhr.f76nr8pf { 223 | display: none; 224 | } 225 | 226 | /* Narrow: Hide preferences button */ 227 | html.sidebar-force-narrow .pvreidsc.r227ecj6.n68fow1o.gt60zsk1.lth9pzmp { 228 | display: none; 229 | } 230 | /* Narrow: Hide conversation previews */ 231 | html.sidebar-force-narrow .bdao358l.om3e55n1.g4tp4svg.alzwoclg.cqf1kptm.jez8cy9q.gvxzyvdx.aeinzg81.cgu29s5g { 232 | display: none; 233 | } 234 | /* Narrow: Hide search bar */ 235 | html.sidebar-force-narrow .bdao358l.om3e55n1.g4tp4svg.r227ecj6.gt60zsk1.rj2hsocd.f9xcifuu { 236 | display: none; 237 | } 238 | /* Narrow: Width of conversation list */ 239 | html.sidebar-force-narrow .bdao358l.om3e55n1.alzwoclg.cqf1kptm.gvxzyvdx.aeinzg81.jez8cy9q.fawcizw8.sl4bvocy.mm98tyaj.b0ur3jhr.f76nr8pf { 240 | width: 80px; 241 | } 242 | 243 | /* -- Toggle message buttons -- */ 244 | body .qi72231t.o9w3sbdw.nu7423ey.tav9wjvu.flwp5yud.tghlliq5.gkg15gwv.s9ok87oh.s9ljgwtm.lxqftegz.bf1zulr9.frfouenu.bonavkto.djs4p424.r7bn319e.bdao358l.fsf7x5fv.tgm57n0e.jez8cy9q.s5oniofx.m8h3af8h.l7ghb35v.kjdc1dyq.dnr7xe2t.aeinzg81.om3e55n1.cr00lzj9.rn8ck1ys.s3jn8y49.g4tp4svg.o9erhkwx.dzqi5evh.hupbnkgi.hvb2xoa8.fxk3tzhb.jl2a5g8c.f14ij5to.l3ldwz01.icdlwmnq.q46jt4gp.b0eko5f3.r5g9zsuq.fwlpnqze.jbg88c62, 245 | body .s8sjc6am.z6erz7xo.alzwoclg.jcxyg2ei.i85zmo3j.b0ur3jhr.kjdc1dyq.tjkoo78o.iwr3bmeu.qgj99rie { 246 | display: flex; 247 | } 248 | 249 | body.show-message-buttons .qi72231t.o9w3sbdw.nu7423ey.tav9wjvu.flwp5yud.tghlliq5.gkg15gwv.s9ok87oh.s9ljgwtm.lxqftegz.bf1zulr9.frfouenu.bonavkto.djs4p424.r7bn319e.bdao358l.fsf7x5fv.tgm57n0e.jez8cy9q.s5oniofx.m8h3af8h.l7ghb35v.kjdc1dyq.dnr7xe2t.aeinzg81.om3e55n1.cr00lzj9.rn8ck1ys.s3jn8y49.g4tp4svg.o9erhkwx.dzqi5evh.hupbnkgi.hvb2xoa8.fxk3tzhb.jl2a5g8c.f14ij5to.l3ldwz01.icdlwmnq.q46jt4gp.b0eko5f3.r5g9zsuq.fwlpnqze.jbg88c62, 250 | body.show-message-buttons .s8sjc6am.z6erz7xo.alzwoclg.jcxyg2ei.i85zmo3j.b0ur3jhr.kjdc1dyq.tjkoo78o.iwr3bmeu.qgj99rie { 251 | display: none; 252 | } 253 | 254 | body.show-message-buttons .cgu29s5g.alzwoclg.oo5upp5e { 255 | margin-left: 15px !important; 256 | } 257 | -------------------------------------------------------------------------------- /css/code-blocks.css: -------------------------------------------------------------------------------- 1 | /* Tomorrow light theme for code blocks */ 2 | ._wu0 { 3 | --code-block-base: #1d1f21; 4 | --code-block-background: transparent; 5 | --code-block-border: rgb(0 0 0 / 10%); 6 | --code-block-primary: #de935f; 7 | --code-block-meta: #969896; 8 | --code-block-tag: #a3685a; 9 | --code-block-quoted: #b5bd68; 10 | --code-block-variable: #c66; 11 | --code-block-special: #8abeb7; 12 | --code-block-attr-value: #139543; 13 | --code-block-keyword: #b294bb; 14 | --code-block-function: #81a2be; 15 | background-color: var(--code-block-background) !important; 16 | border: 1px solid var(--code-block-border) !important; 17 | color: var(--code-block-base) !important; 18 | } 19 | ._wu0 .token.punctuation { 20 | color: var(--code-block-base) !important; 21 | } 22 | ._wu0 .token.property { 23 | color: var(--code-block-base) !important; 24 | } 25 | ._wu0 .token.operator { 26 | color: var(--code-block-base) !important; 27 | } 28 | ._wu0 .token.boolean { 29 | color: var(--code-block-primary) !important; 30 | } 31 | ._wu0 .token.number { 32 | color: var(--code-block-primary) !important; 33 | } 34 | ._wu0 .token.constant { 35 | color: var(--code-block-primary) !important; 36 | } 37 | ._wu0 .token.selector { 38 | color: var(--code-block-primary) !important; 39 | } 40 | ._wu0 .token.bold { 41 | color: var(--code-block-primary) !important; 42 | font-weight: bold; 43 | } 44 | ._wu0 .token.comment { 45 | color: var(--code-block-meta) !important; 46 | } 47 | ._wu0 .token.prolog { 48 | color: var(--code-block-meta) !important; 49 | } 50 | ._wu0 .token.doctype { 51 | color: var(--code-block-meta) !important; 52 | } 53 | ._wu0 .token.cdata { 54 | color: var(--code-block-meta) !important; 55 | } 56 | ._wu0 .token.tag { 57 | color: var(--code-block-tag) !important; 58 | } 59 | ._wu0 .token.symbol { 60 | color: var(--code-block-quoted) !important; 61 | } 62 | ._wu0 .token.string { 63 | color: var(--code-block-quoted) !important; 64 | } 65 | ._wu0 .token.char { 66 | color: var(--code-block-quoted) !important; 67 | } 68 | ._wu0 .token.inserted { 69 | color: var(--code-block-quoted) !important; 70 | } 71 | ._wu0 .token.attr-name { 72 | color: var(--code-block-variable) !important; 73 | } 74 | ._wu0 .token.url { 75 | color: var(--code-block-variable) !important; 76 | } 77 | ._wu0 .token.entity { 78 | color: var(--code-block-variable) !important; 79 | } 80 | ._wu0 .token.variable { 81 | color: var(--code-block-variable) !important; 82 | } 83 | ._wu0 .token.deleted { 84 | color: var(--code-block-variable) !important; 85 | } 86 | ._wu0 .token.builtin { 87 | color: var(--code-block-special) !important; 88 | } 89 | ._wu0 .token.hexcode { 90 | color: var(--code-block-special) !important; 91 | } 92 | ._wu0 .token.regex { 93 | color: var(--code-block-special) !important; 94 | } 95 | ._wu0 .token.attr-value { 96 | color: var(--code-block-attr-value) !important; 97 | } 98 | ._wu0 .token.keyword { 99 | color: var(--code-block-keyword) !important; 100 | } 101 | ._wu0 .token.important { 102 | color: var(--code-block-keyword) !important; 103 | } 104 | ._wu0 .token.italic { 105 | color: var(--code-block-keyword) !important; 106 | font-style: italic; 107 | } 108 | ._wu0 .token.function { 109 | color: var(--code-block-function) !important; 110 | } 111 | 112 | /* Tomorrow dark theme for code blocks */ 113 | html.dark-mode ._wu0 { 114 | --code-block-base: #c5c8c6; 115 | --code-block-border: var(--base-ten); 116 | color: var(--base); 117 | } 118 | 119 | /* Full-window vibrancy */ 120 | html.full-vibrancy ._wu0 { 121 | --code-block-background: #fff; 122 | --code-block-border: transparent; 123 | } 124 | html.full-vibrancy.dark-mode ._wu0 { 125 | --code-block-background: var(--container-color); 126 | } 127 | -------------------------------------------------------------------------------- /css/dark-mode.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --base: #000; 3 | --base-ninety: rgb(255 255 255 / 90%); 4 | --base-seventy-five: rgb(255 255 255 / 75%); 5 | --base-seventy: rgb(255 255 255 / 70%); 6 | --base-fifty: rgb(255 255 255 / 50%); 7 | --base-fourty: rgb(255 255 255 / 40%); 8 | --base-thirty: rgb(255 255 255 / 30%); 9 | --base-twenty: rgb(255 255 255 / 20%); 10 | --base-ten: rgb(255 255 255 / 10%); 11 | --base-nine: rgb(255 255 255 / 9%); 12 | --base-five: rgb(255 255 255 / 5%); 13 | --container-color: #323232; 14 | --container-dark-color: #1e1e1e; 15 | --list-header-color: #222; 16 | --blue: #0084ff; 17 | --white: #fff; 18 | } 19 | 20 | html.dark-mode body { 21 | color: var(--base-seventy); 22 | background: var(--container-color) !important; 23 | } 24 | 25 | /* Fixes appearance of "Verify Account" screen text */ 26 | html.dark-mode ._3-mr ._3-mt, 27 | html.dark-mode ._3-mr ._3-mu { 28 | color: #fff; 29 | } 30 | 31 | html.dark-mode ._3v_o, /* Login screen */ 32 | html.dark-mode body.UIPage_LoggedOut ._li, /* 2FA screen */ 33 | html.dark-mode body.UIPage_LoggedOut ._4-u5 /* 2FA screen */ { 34 | background-color: var(--container-dark-color); 35 | } 36 | 37 | /* Login title and names */ 38 | html.dark-mode ._5hy4, 39 | html.dark-mode ._3403 { 40 | color: var(--base-fourty) !important; 41 | } 42 | 43 | /* Login inputs */ 44 | html.dark-mode ._3v_o ._55r1 { 45 | background: var(--base-five); 46 | color: var(--base-seventy); 47 | } 48 | html.dark-mode ._3v_o ._55r1::-webkit-input-placeholder { 49 | color: var(--base-thirty) !important; 50 | } 51 | 52 | /* "Keep me signed in" checkbox */ 53 | html.dark-mode .uiInputLabelInput { 54 | filter: opacity(70%); 55 | } 56 | 57 | /* "Keep me signed in" text */ 58 | html.dark-mode .uiInputLabelLabel { 59 | color: var(--base-fourty) !important; 60 | } 61 | 62 | /* 2FA screen modal */ 63 | html.dark-mode body.UIPage_LoggedOut ._4-u8 { 64 | background: var(--container-color); 65 | border-color: var(--base-five) !important; 66 | } 67 | 68 | /* 2FA screen modal title */ 69 | html.dark-mode body.UIPage_LoggedOut ._2e9n { 70 | border-color: var(--base-five); 71 | color: #fff; 72 | } 73 | 74 | /* 2FA screen modal separator */ 75 | html.dark-mode body.UIPage_LoggedOut ._p0k ._5hzs { 76 | border-color: var(--base-five); 77 | } 78 | 79 | /* 2FA screen modal separators */ 80 | html.dark-mode body.UIPage_LoggedOut a { 81 | color: var(--blue); 82 | } 83 | 84 | /* 2FA screen modal input */ 85 | html.dark-mode body.UIPage_LoggedOut input { 86 | background: var(--base-ten); 87 | border-color: var(--base-ten); 88 | color: var(--base-ninety); 89 | } 90 | 91 | /* Cookies notification: background */ 92 | html.dark-mode ._9o-w ._9o-c { 93 | background: var(--container-color) !important; 94 | } 95 | /* Cookies notification: text */ 96 | html.dark-mode ._9o-g { 97 | color: var(--base-seventy) !important; 98 | } 99 | /* Cookies notification: collapsible headers */ 100 | html.dark-mode ._9o-l { 101 | color: var(--base-seventy) !important; 102 | } 103 | /* Cookies notification: subheaders */ 104 | html.dark-mode ._9si- { 105 | color: var(--base-seventy) !important; 106 | } 107 | /* Cookies notification: hamburger menu */ 108 | html.dark-mode ._42ft._4jy0._55pi._2agf._4o_4._9o-e._p._4jy3._517h._51sy { 109 | background: var(--container-color) !important; 110 | } 111 | /* Cookies notification: hamburger menu background */ 112 | html.dark-mode ._54ng { 113 | background: var(--container-color) !important; 114 | } 115 | /* Cookies notification: hamburger menu text */ 116 | html.dark-mode ._54nh { 117 | color: var(--base-seventy) !important; 118 | } 119 | /* Cookies notification: hamburger menu column borders */ 120 | html.dark-mode ._54nc { 121 | border-color: var(--container-color) !important; 122 | } 123 | /* Cookies notification: icons */ 124 | html.dark-mode .img.sp_ng1YXMZLXub { 125 | filter: invert(0.66); 126 | } 127 | /* Cookies notification: rectangular boxes */ 128 | html.dark-mode .pam._9o-n.uiBoxGray { 129 | background-color: var(--base-ten) !important; 130 | } 131 | html.dark-mode ._9xq0 { 132 | color: var(--base-seventy) !important; 133 | } 134 | 135 | /* Top bar: App menu button color */ 136 | /* Top bar: New message button color */ 137 | .j83agx80.pfnyh3mw .ozuftl9m .a8c37x1j.ms05siws.hwsy1cff.b7h9ocf4 { 138 | fill: currentcolor; 139 | color: var(--primary-text); 140 | } 141 | 142 | /* Chat list: Mute icon */ 143 | .bp9cbjyn.j83agx80.btwxx1t3 .dlv3wnog.lupvgy83 .a8c37x1j { 144 | fill: currentcolor; 145 | color: var(--primary-text); 146 | } 147 | 148 | /* Right sidebar: icons */ 149 | .x1qhmfi1.x14yjl9h.xudhj91.x18nykt9.xww2gxu.x1fgtraw.x1264ykn.x78zum5.x6s0dn4.xl56j7k svg path { 150 | fill: currentcolor; 151 | color: var(--primary-text); 152 | } 153 | 154 | /* Contact list: delivered icon color */ 155 | .aahdfvyu [role='grid'] .a8c37x1j.ms05siws.hwsy1cff.b7h9ocf4 { 156 | fill: currentcolor; 157 | color: var(--primary-text); 158 | } 159 | 160 | /* Messenger settings: Privacy & safety icon color */ 161 | .x1lliihq.x1k90msu.x2h7rmj.x1qfuztq.x198g3q0.xxk0z11.xvy4d1p { 162 | fill: currentcolor; 163 | color: var(--primary-text); 164 | } 165 | 166 | /* Radio buttons */ 167 | .x14yjl9h.xudhj91.x18nykt9.xww2gxu.x13fuv20.xu3j5b3.x1q0q8m5.x26u7qi.xamhcws.xol2nv.xlxy82.x19p7ews.x9f619.x1rg5ohu.x2lah0s.x1n2onr6.x1tz4bnf.xmds5ef.x25epmt.x11y6y4w.xxk0z11.xvy4d1p { 168 | --accent: var(--primary-text); 169 | } 170 | 171 | /* Create room icon color */ 172 | html.dark-mode .x1p6odiv { 173 | color: var(--primary-icon); 174 | } 175 | -------------------------------------------------------------------------------- /css/scrollbar.css: -------------------------------------------------------------------------------- 1 | /* Custom native scrollbar */ 2 | 3 | /* Light theme */ 4 | ::-webkit-scrollbar { 5 | width: 16px; 6 | } 7 | ::-webkit-scrollbar-thumb { 8 | box-shadow: inset 0 0 16px 16px #bcc0c4; 9 | border-radius: 8px; 10 | border: solid 4px transparent; 11 | } 12 | ::-webkit-scrollbar-track { 13 | box-shadow: inset 0 0 16px 16px #fff; 14 | } 15 | ::-webkit-scrollbar-track:hover { 16 | box-shadow: inset 0 0 16px 16px #f0f1f2; 17 | } 18 | 19 | /* Dark theme */ 20 | html.dark-mode ::-webkit-scrollbar { 21 | width: 16px; 22 | } 23 | html.dark-mode ::-webkit-scrollbar-thumb { 24 | box-shadow: inset 0 0 16px 16px #ffffff4c; 25 | border-radius: 8px; 26 | border: solid 4px transparent; 27 | } 28 | html.dark-mode ::-webkit-scrollbar-track { 29 | box-shadow: inset 0 0 16px 16px #0000; 30 | } 31 | html.dark-mode ::-webkit-scrollbar-track:hover { 32 | box-shadow: inset 0 0 16px 16px #ffffff10; 33 | } 34 | -------------------------------------------------------------------------------- /css/vibrancy.css: -------------------------------------------------------------------------------- 1 | /* -- BLOCK START: sidebar-only vibrancy -- */ 2 | 3 | html.sidebar-vibrancy body, 4 | html.sidebar-vibrancy ._4sp8, 5 | html.sidebar-vibrancy.dark-mode body, 6 | html.sidebar-vibrancy.dark-mode ._4sp8 { 7 | background: transparent !important; 8 | } 9 | 10 | /* Login screen */ 11 | html.sidebar-vibrancy ._3v_o { 12 | background-color: #fff; 13 | } 14 | html.sidebar-vibrancy.dark-mode ._3v_o { 15 | background-color: var(--container-dark-color); 16 | } 17 | 18 | /* Message placeholder text color */ 19 | html.sidebar-vibrancy ._kmc ._1p1t { 20 | color: #999 !important; 21 | -webkit-text-fill-color: #999 !important; 22 | } 23 | 24 | /* Contact list: header above */ 25 | html.sidebar-vibrancy.dark-mode ._36ic { 26 | background: transparent !important; 27 | } 28 | 29 | /* Contact list: search input */ 30 | html.sidebar-vibrancy ._5iwm ._58al { 31 | background-color: rgb(246 247 249 / 50%) !important; 32 | } 33 | html.sidebar-vibrancy.dark-mode ._5iwm ._58al { 34 | background: var(--base-five) !important; 35 | } 36 | 37 | /* Chat title bar */ 38 | html.sidebar-vibrancy ._673w { 39 | background-color: #fff !important; 40 | } 41 | 42 | html.sidebar-vibrancy.dark-mode ._673w { 43 | background-color: var(--container-dark-color) !important; 44 | border-bottom: 1px solid var(--base-five) !important; 45 | } 46 | 47 | /* Share previews: title and subtitle */ 48 | html.sidebar-vibrancy .__6k, 49 | html.sidebar-vibrancy .__6l { 50 | background-color: transparent !important; 51 | } 52 | 53 | /* Message container + right sidebar */ 54 | html.sidebar-vibrancy ._4_j4, 55 | html.sidebar-vibrancy ._4_j5 { 56 | background: #fff !important; 57 | } 58 | html.sidebar-vibrancy.dark-mode ._4_j4, 59 | html.sidebar-vibrancy.dark-mode ._4_j5 { 60 | background: var(--container-dark-color) !important; 61 | } 62 | 63 | /* Message list: header above */ 64 | html.sidebar-vibrancy.dark-mode ._5742 { 65 | background: transparent !important; 66 | } 67 | 68 | /* New conversation name input field */ 69 | html.sidebar-vibrancy ._2y8y { 70 | background: #fff !important; 71 | } 72 | html.sidebar-vibrancy.dark-mode ._2y8y { 73 | background: var(--container-dark-color) !important; 74 | } 75 | 76 | /* Message text bar */ 77 | html.sidebar-vibrancy.dark-mode ._5irm._7mkm { 78 | background: var(--container-dark-color); 79 | } 80 | 81 | /* -- BLOCK END: sidebar-only vibrancy -- */ 82 | 83 | /* -- BLOCK START: full-window vibrancy -- */ 84 | 85 | html.full-vibrancy body, 86 | html.full-vibrancy ._4sp8 { 87 | background: transparent !important; 88 | } 89 | 90 | 91 | html.full-vibrancy ._5hy2 ._43dh, /* Login button */ 92 | html.full-vibrancy ._3-mr ._3-mv /* Verification "Continue" button */ { 93 | background-color: transparent !important; 94 | } 95 | 96 | /* Message placeholder text color */ 97 | html.full-vibrancy ._kmc ._1p1t { 98 | color: #999 !important; 99 | -webkit-text-fill-color: #999 !important; 100 | } 101 | 102 | /* Messages list: start conversation with a chat bot */ 103 | html.full-vibrancy ._2xh0 ._3zc8 { 104 | background-color: transparent; 105 | } 106 | 107 | /* Messages list: start conversation with a chat bot buttons */ 108 | html.full-vibrancy ._2xh0 ._2xh4 { 109 | background-color: transparent; 110 | } 111 | 112 | /* Contact list: search input */ 113 | html.full-vibrancy ._5iwm ._58al { 114 | background-color: rgb(246 247 249 / 50%) !important; 115 | } 116 | html.full-vibrancy.dark-mode ._5iwm ._58al { 117 | background: var(--base-five) !important; 118 | } 119 | 120 | /* Chat title bar */ 121 | html.full-vibrancy ._673w, 122 | html.full-vibrancy.dark-mode ._673w { 123 | background-color: transparent !important; 124 | } 125 | 126 | /* Share previews: title and subtitle */ 127 | html.full-vibrancy .__6k, 128 | html.full-vibrancy .__6l { 129 | background-color: transparent !important; 130 | } 131 | 132 | /* Contact list: person container */ 133 | html.full-vibrancy ._1qt4 { 134 | border-top: solid 1px rgb(0 0 0 / 6%); 135 | } 136 | 137 | /* Main content */ 138 | html.full-vibrancy.dark-mode ._1q5- { 139 | background: transparent !important; 140 | } 141 | 142 | /* Message list: header above */ 143 | html.full-vibrancy.dark-mode ._5742 { 144 | background: transparent !important; 145 | } 146 | 147 | /* Contact list: header above */ 148 | html.full-vibrancy.dark-mode ._36ic { 149 | background: transparent !important; 150 | } 151 | 152 | /* Message container + right sidebar */ 153 | html.full-vibrancy ._4_j4, 154 | html.full-vibrancy ._4_j5, 155 | html.full-vibrancy.dark-mode ._4_j4, 156 | html.full-vibrancy.dark-mode ._4_j5 { 157 | background: transparent !important; 158 | } 159 | 160 | /* New conversation name input field */ 161 | html.full-vibrancy ._2y8y, 162 | html.full-vibrancy.dark-mode ._2y8y { 163 | background: transparent !important; 164 | } 165 | 166 | /* Message composer buttons */ 167 | html.full-vibrancy ._39bj { 168 | filter: brightness(0.8); 169 | } 170 | html.full-vibrancy.dark-model ._39bj { 171 | filter: brightness(1); 172 | } 173 | 174 | /* Deleted message */ 175 | html.full-vibrancy ._7301._hh7 { 176 | background-color: #fff; 177 | border-color: transparent !important; 178 | } 179 | html.full-vibrancy.dark-mode ._7301._hh7 { 180 | background-color: var(--container-color); 181 | } 182 | 183 | /* Message list: link preview */ 184 | html.full-vibrancy ._5i_d { 185 | background-color: #fff; 186 | border-color: transparent !important; 187 | } 188 | html.full-vibrancy.dark-mode ._5i_d { 189 | background-color: var(--container-color); 190 | } 191 | 192 | /* Message composer: attached files */ 193 | html.full-vibrancy ._2zl5 { 194 | background-color: #fff; 195 | border-color: transparent; 196 | } 197 | html.full-vibrancy.dark-mode ._2zl5 { 198 | background-color: var(--container-color); 199 | } 200 | 201 | /* Reply tag icon */ 202 | html.full-vibrancy ._6e38 { 203 | filter: brightness(0.66); 204 | } 205 | 206 | /* Message composer: link preview */ 207 | html.full-vibrancy .chatAttachmentShelf, 208 | html.full-vibrancy .fbNubFlyoutAttachments, 209 | html.full-vibrancy.dark-mode .chatAttachmentShelf, 210 | html.full-vibrancy.dark-mode .fbNubFlyoutAttachments { 211 | background: transparent !important; 212 | } 213 | html.full-vibrancy .chatAttachmentShelf, 214 | html.full-vibrancy.dark-mode .chatAttachmentShelf { 215 | border-top-color: rgb(0 0 0 / 10%); 216 | } 217 | 218 | /* Message text bar */ 219 | html.full-vibrancy ._5irm._7mkm { 220 | background: transparent; 221 | } 222 | 223 | /* Additional "plus" bar */ 224 | html.full-vibrancy ._7mkk._7t1o._7t0j { 225 | display: none; 226 | } 227 | /* -- BLOCK END: full-window vibrancy -- */ 228 | -------------------------------------------------------------------------------- /css/workchat.css: -------------------------------------------------------------------------------- 1 | /* Main: Hide header */ 2 | #pagelet_bluebar { 3 | display: none; 4 | } 5 | 6 | /* Login: Remove vertical scrollbar */ 7 | body { 8 | overflow: hidden !important; 9 | } 10 | 11 | /* Login: Remove white top bar */ 12 | ._4b21 { 13 | display: none; 14 | } 15 | 16 | /* Login: Remove Facebook links */ 17 | #pageFooter { 18 | display: none; 19 | } 20 | 21 | /* Login: Vertically center login box */ 22 | html { 23 | height: 100%; 24 | } 25 | body { 26 | height: 100%; 27 | } 28 | .UIPage_LoggedOut ._li { 29 | height: 100%; 30 | } 31 | #globalContainer { 32 | display: flex; 33 | flex-direction: column; 34 | justify-content: center; 35 | height: 100%; 36 | } 37 | ._5rw2 { 38 | min-height: 0 !important; 39 | padding: 0 !important; 40 | } 41 | 42 | /** 43 | * Dark Mode 44 | */ 45 | 46 | /* Login: Background */ 47 | html.dark-mode ._5rw0 { 48 | color: var(--base-seventy); 49 | background-color: transparent !important; 50 | } 51 | 52 | /* Login: Logo */ 53 | html.dark-mode .sx_2c5ee7 { 54 | filter: brightness(4); 55 | } 56 | 57 | /* Login: Slogan */ 58 | html.dark-mode ._5zi0 { 59 | filter: brightness(3); 60 | } 61 | 62 | /* Login: Fix background color in vibrancy mode */ 63 | html.vibrancy ._5rw0 { 64 | background-color: transparent !important; 65 | } 66 | html.vibrancy:not(.dark-mode) ._5rw2 { 67 | color: #242424 !important; 68 | } 69 | 70 | /* Login: Login button */ 71 | html.dark-mode.vibrancy button { 72 | background: var(--container-color) !important; 73 | } 74 | 75 | /* Login: Remove login button border */ 76 | html.dark-mode button { 77 | border-color: var(--container-color) !important; 78 | } 79 | 80 | /* Login: Email confirmation screen */ 81 | html.dark-mode ._5rwd { 82 | color: var(--base-seventy); 83 | background: var(--container-color) !important; 84 | } 85 | 86 | /* Contact List: Search results */ 87 | html.dark-mode ._4p-s { 88 | background: var(--base-ten) !important; 89 | } 90 | 91 | html.dark-mode ._aou { 92 | background: var(--base) !important; 93 | } 94 | 95 | html.dark-mode ._4kf5 { 96 | background: var(--base) !important; 97 | } 98 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /media/AppIcon-readme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/media/AppIcon-readme.png -------------------------------------------------------------------------------- /media/AppIcon.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/media/AppIcon.pdf -------------------------------------------------------------------------------- /media/AppIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/media/AppIcon.png -------------------------------------------------------------------------------- /media/AppIcon.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/media/AppIcon.sketch -------------------------------------------------------------------------------- /media/screenshot-block-typing-indicator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/media/screenshot-block-typing-indicator.png -------------------------------------------------------------------------------- /media/screenshot-codeblocks-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/media/screenshot-codeblocks-dark.png -------------------------------------------------------------------------------- /media/screenshot-codeblocks-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/media/screenshot-codeblocks-light.png -------------------------------------------------------------------------------- /media/screenshot-compact.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/media/screenshot-compact.png -------------------------------------------------------------------------------- /media/screenshot-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/media/screenshot-dark.png -------------------------------------------------------------------------------- /media/screenshot-dock-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/media/screenshot-dock-menu.png -------------------------------------------------------------------------------- /media/screenshot-hide-notification-message-after.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/media/screenshot-hide-notification-message-after.png -------------------------------------------------------------------------------- /media/screenshot-hide-notification-message-before.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/media/screenshot-hide-notification-message-before.png -------------------------------------------------------------------------------- /media/screenshot-hide-notification-message-location.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/media/screenshot-hide-notification-message-location.png -------------------------------------------------------------------------------- /media/screenshot-menu-bar-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/media/screenshot-menu-bar-menu.png -------------------------------------------------------------------------------- /media/screenshot-menu-bar-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/media/screenshot-menu-bar-mode.png -------------------------------------------------------------------------------- /media/screenshot-notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/media/screenshot-notification.png -------------------------------------------------------------------------------- /media/screenshot-touchbar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/media/screenshot-touchbar.png -------------------------------------------------------------------------------- /media/screenshot-vibrancy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/media/screenshot-vibrancy.jpg -------------------------------------------------------------------------------- /media/screenshot-work-chat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/media/screenshot-work-chat.png -------------------------------------------------------------------------------- /media/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/media/screenshot.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "caprine", 3 | "productName": "Caprine", 4 | "version": "2.60.3", 5 | "description": "Elegant Facebook Messenger desktop app", 6 | "license": "MIT", 7 | "repository": "sindresorhus/caprine", 8 | "author": { 9 | "name": "Sindre Sorhus", 10 | "email": "sindresorhus@gmail.com", 11 | "url": "https://sindresorhus.com" 12 | }, 13 | "main": "dist-js", 14 | "engines": { 15 | "node": ">=16" 16 | }, 17 | "scripts": { 18 | "postinstall": "patch-package && electron-builder install-app-deps", 19 | "lint:xo": "xo", 20 | "lint:stylelint": "stylelint \"css/**/*.css\"", 21 | "lint": "npm run lint:xo && npm run lint:stylelint", 22 | "test:tsc": "npm run build", 23 | "test": "npm run test:tsc && npm run lint", 24 | "start": "tsc && electron .", 25 | "build": "tsc", 26 | "dist:linux": "electron-builder --linux", 27 | "dist:mac": "electron-builder --mac", 28 | "dist:win": "electron-builder --win", 29 | "release": "np --no-publish" 30 | }, 31 | "dependencies": { 32 | "@electron/remote": "^2.1.2", 33 | "@sindresorhus/do-not-disturb": "^1.1.0", 34 | "electron-better-ipc": "^2.0.1", 35 | "electron-context-menu": "^3.6.1", 36 | "electron-debug": "^3.2.0", 37 | "electron-dl": "^3.5.2", 38 | "electron-localshortcut": "^3.2.1", 39 | "electron-store": "^8.1.0", 40 | "electron-updater": "^6.1.8", 41 | "electron-util": "^0.17.2", 42 | "element-ready": "^5.0.0", 43 | "facebook-locales": "^1.0.916", 44 | "is-online": "^9.0.1", 45 | "json-schema-typed": "^8.0.1", 46 | "lodash": "^4.17.21", 47 | "npm-check-updates": "^16.14.15", 48 | "p-wait-for": "^3.2.0" 49 | }, 50 | "devDependencies": { 51 | "@sindresorhus/tsconfig": "^0.7.0", 52 | "@types/electron-localshortcut": "^3.1.3", 53 | "@types/facebook-locales": "^1.0.2", 54 | "@types/lodash": "^4.14.202", 55 | "del-cli": "^5.1.0", 56 | "electron": "^29.0.1", 57 | "electron-builder": "^24.12.0", 58 | "husky": "^9.0.11", 59 | "np": "^9.2.0", 60 | "patch-package": "^8.0.0", 61 | "stylelint": "^14.10.0", 62 | "stylelint-config-xo": "^0.22.0", 63 | "typescript": "^5.3.3", 64 | "xo": "^0.57.0" 65 | }, 66 | "xo": { 67 | "envs": [ 68 | "node", 69 | "browser" 70 | ], 71 | "rules": { 72 | "@typescript-eslint/ban-ts-comment": "off", 73 | "@typescript-eslint/consistent-type-imports": "off", 74 | "@typescript-eslint/naming-convention": "off", 75 | "@typescript-eslint/no-floating-promises": "off", 76 | "@typescript-eslint/no-loop-func": "off", 77 | "@typescript-eslint/no-non-null-assertion": "off", 78 | "@typescript-eslint/no-require-imports": "off", 79 | "@typescript-eslint/no-unsafe-argument": "off", 80 | "@typescript-eslint/no-unsafe-assignment": "off", 81 | "@typescript-eslint/no-unsafe-call": "off", 82 | "@typescript-eslint/no-unsafe-enum-comparison": "off", 83 | "@typescript-eslint/no-var-requires": "off", 84 | "import/extensions": "off", 85 | "import/no-anonymous-default-export": "off", 86 | "import/no-cycle": "off", 87 | "n/file-extension-in-import": "off", 88 | "unicorn/prefer-at": "off", 89 | "unicorn/prefer-module": "off", 90 | "unicorn/prefer-top-level-await": "off" 91 | } 92 | }, 93 | "stylelint": { 94 | "extends": "stylelint-config-xo", 95 | "rules": { 96 | "declaration-no-important": null, 97 | "no-descending-specificity": null, 98 | "no-duplicate-selectors": null, 99 | "rule-empty-line-before": null, 100 | "selector-class-pattern": null, 101 | "selector-id-pattern": null, 102 | "selector-max-class": null 103 | } 104 | }, 105 | "np": { 106 | "publish": false, 107 | "releaseDraft": false 108 | }, 109 | "build": { 110 | "files": [ 111 | "**/*", 112 | "!media${/*}" 113 | ], 114 | "asarUnpack": [ 115 | "static/Icon.png" 116 | ], 117 | "appId": "com.sindresorhus.caprine", 118 | "mac": { 119 | "category": "public.app-category.social-networking", 120 | "icon": "build/icon.icns", 121 | "electronUpdaterCompatibility": ">=4.5.2", 122 | "darkModeSupport": true, 123 | "target": { 124 | "target": "default", 125 | "arch": [ 126 | "x64", 127 | "arm64" 128 | ] 129 | }, 130 | "extendInfo": { 131 | "LSUIElement": 1, 132 | "NSCameraUsageDescription": "Caprine needs access to your camera.", 133 | "NSMicrophoneUsageDescription": "Caprine needs access to your microphone." 134 | } 135 | }, 136 | "dmg": { 137 | "iconSize": 160, 138 | "contents": [ 139 | { 140 | "x": 180, 141 | "y": 170 142 | }, 143 | { 144 | "x": 480, 145 | "y": 170, 146 | "type": "link", 147 | "path": "/Applications" 148 | } 149 | ] 150 | }, 151 | "linux": { 152 | "target": [ 153 | "AppImage", 154 | "deb" 155 | ], 156 | "icon": "build/icons/", 157 | "synopsis": "Elegant Facebook Messenger desktop app", 158 | "description": "Caprine is an unofficial and privacy focused Facebook Messenger app with many useful features.", 159 | "category": "Network;Chat" 160 | }, 161 | "snap": { 162 | "plugs": [ 163 | "default", 164 | "camera", 165 | "removable-media" 166 | ], 167 | "publish": [ 168 | { 169 | "provider": "github" 170 | }, 171 | { 172 | "provider": "snapStore", 173 | "channels": [ 174 | "stable" 175 | ] 176 | } 177 | ] 178 | }, 179 | "win": { 180 | "verifyUpdateCodeSignature": false, 181 | "icon": "build/icon.png" 182 | }, 183 | "nsis": { 184 | "oneClick": false, 185 | "perMachine": false, 186 | "allowToChangeInstallationDirectory": true 187 | } 188 | }, 189 | "husky": { 190 | "hooks": { 191 | "pre-push": "npm test" 192 | } 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /packages/deb/addRepo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Made by Lefteris Garyfalakis 3 | # Last update: 25-08-2022 4 | 5 | COL_NC='\e[0m' # No Color 6 | COL_LIGHT_RED='\e[1;31m' 7 | COL_LIGHT_GREEN='\e[1;32m' 8 | COL_LIGHT_BLUE='\e[1;94m' 9 | COL_LIGHT_YELLOW='\e[1;93m' 10 | TICK="${COL_NC}[${COL_LIGHT_GREEN}✓${COL_NC}]" 11 | CROSS="${COL_NC}[${COL_LIGHT_RED}✗${COL_NC}]" 12 | INFO="${COL_NC}[${COL_LIGHT_YELLOW}i${COL_NC}]" 13 | QUESTION="${COL_NC}[${COL_LIGHT_BLUE}?${COL_NC}]" 14 | 15 | APT_SOURCE_PATH="/etc/apt/sources.list.d/caprine.list" 16 | APT_SOURCE_CONTENT="deb [trusted=yes] https://apt.fury.io/lefterisgar/ * *" 17 | 18 | clear 19 | 20 | # Print ASCII logo and branding 21 | printf " ⠀⠀⠀⠀⠀⠀⠀⢀⣠⣤⣤⣶⣶⣶⣶⣤⣤⣄⡀⠀⠀⠀⠀⠀⠀⠀ 22 | ⠀⠀⢀⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣦⡀⠀⠀⠀⠀ 23 | ⠀⠀⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⡀⠀⠀ 24 | ⠀⢀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀⠀ 25 | ⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⢿⣿⣿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⠀ 26 | ⢰⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠀⠀⠙⠿⠿⠛⠉⣠⣾⣿⣿⣿⣿⣿⡆ 27 | ⢸⣿⣿⣿⣿⣿⣿⠟⠁⢀⣠⣄⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣿⣿⣿⡇ 28 | ⠈⣿⣿⣿⣿⣟⣥⣶⣾⣿⣿⣿⣷⣦⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁ 29 | ⠀⠹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠀ 30 | ⠀⠀⠙⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠋⠀⠀ 31 | ⠀⠀⠈⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⠁⠀⠀⠀ 32 | ⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠛⠉⠀⠀⠀⠀⠀⠀ 33 | ⠀⠀⠀⢸⡿⠛⠋\n\n" 34 | 35 | printf " ___ _ 36 | / __|__ _ _ __ _ _(_)_ _ ___ 37 | | (__/ _\` | '_ \ '_| | ' \/ -_) 38 | \___\__,_| .__/_| |_|_||_\___| 39 | |_|\n\n" 40 | 41 | printf " Elegant Facebook Messenger desktop app\n\n" 42 | 43 | printf "*** Caprine installation script ***\n" 44 | printf -- "-----------------------------------\n" 45 | printf "Author : Lefteris Garyfalakis\n" 46 | printf "Last update : 25-08-2022\n" 47 | printf -- "-----------------------------------\n" 48 | 49 | printf "%b %bDetecting APT...%b\\n" "${INFO}" 50 | 51 | # Check if APT is installed 52 | if hash apt 2>/dev/null; then 53 | printf "%b %b$(apt -v)%b\\n" "${TICK}" "${COL_LIGHT_GREEN}" "${COL_NC}" 54 | else 55 | printf "%b %bAPT was not detected!%b\\n" "${CROSS}" "${COL_LIGHT_RED}" "${COL_NC}" 56 | printf "%b %bIs your distribution Debian-based? %b" "${QUESTION}" 57 | exit 2 58 | fi 59 | 60 | # Add Caprine's APT repository 61 | printf "%b %bAdding APT repository...%b" "${INFO}" 62 | 63 | # Disable globbing 64 | set -f 65 | 66 | echo $APT_SOURCE_CONTENT | sudo tee $APT_SOURCE_PATH > /dev/null 67 | 68 | # Enable globbing 69 | set +f 70 | 71 | if [[ $(< $APT_SOURCE_PATH) == "$APT_SOURCE_CONTENT" ]]; then 72 | printf " Done!\n" 73 | else 74 | printf "\n%b %bError adding APT repository!%b\\n" "${CROSS}" "${COL_LIGHT_RED}" "${COL_NC}" 75 | printf "%b %bReason: Content mismatch%b\\n" "${CROSS}" "${COL_LIGHT_RED}" "${COL_NC}" 76 | exit 5 77 | fi 78 | 79 | # Update the repositories 80 | printf "%b %bUpdating repositories...%b" "${INFO}" 81 | 82 | sudo apt update > /dev/null 2>&1 83 | 84 | printf " Done!\n" 85 | 86 | # Ask the user if he wants to install Caprine 87 | printf "%b %bDo you want to install Caprine? (y/n) %b" "${QUESTION}" 88 | read -n 1 -r < /dev/tty 89 | printf "\n" 90 | 91 | if [[ $REPLY =~ ^[Yy]$ ]]; then 92 | printf "%b %bInstalling Caprine...%b" "${INFO}" 93 | 94 | sudo apt install -y caprine > /dev/null 2>&1 95 | 96 | printf " Done!\n" 97 | else 98 | printf "%b %bOperation cancelled by the user. Aborting.%b\\n" "${CROSS}" "${COL_LIGHT_RED}" "${COL_NC}" 99 | exit 125; 100 | fi 101 | -------------------------------------------------------------------------------- /packages/rpm/caprine.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=Application 3 | Name=Caprine 4 | GenericName=IM Client 5 | Comment=Elegant Facebook Messenger desktop app 6 | Icon=caprine 7 | Exec=caprine 8 | Keywords=Messenger;Facebook;Chat; 9 | Categories=GTK;InstantMessaging;Network; 10 | StartupNotify=true 11 | -------------------------------------------------------------------------------- /packages/rpm/caprine.spec: -------------------------------------------------------------------------------- 1 | %define debug_package %{nil} 2 | %global _build_id_links alldebug 3 | 4 | Name: caprine 5 | Version: 2.60.3 6 | Release: 1%{?dist} 7 | Summary: Elegant Facebook Messenger desktop app 8 | 9 | License: MIT 10 | URL: https://github.com/sindresorhus/caprine/ 11 | Source0: https://github.com/sindresorhus/caprine/archive/refs/tags/v%{version}.tar.gz 12 | Source1: %{name}.desktop 13 | 14 | ExclusiveArch: x86_64 15 | BuildRequires: npm 16 | BuildRequires: nodejs >= 20.0.0 17 | 18 | %description 19 | Caprine is an unofficial and privacy-focused Facebook Messenger app with many useful features. 20 | 21 | %prep 22 | %autosetup 23 | 24 | %build 25 | npm install --silent --no-progress 26 | node_modules/.bin/tsc 27 | node_modules/.bin/electron-builder --linux dir 28 | 29 | %install 30 | install -d %{buildroot}%{_libdir}/%{name} 31 | cp -r dist/linux-unpacked/* %{buildroot}%{_libdir}/%{name} 32 | 33 | install -d %{buildroot}%{_bindir} 34 | ln -sf %{_libdir}/%{name}/%{name} %{buildroot}%{_bindir}/%{name} 35 | 36 | install -Dm644 build/icon.png %{buildroot}%{_datadir}/pixmaps/%{name}.png 37 | 38 | install -d %{buildroot}%{_datadir}/applications 39 | install -Dm644 %{SOURCE1} %{buildroot}%{_datadir}/applications/%{name}.desktop 40 | 41 | install -d %{buildroot}%{_datadir}/licenses/%{name} 42 | install -Dm644 license %{buildroot}%{_datadir}/licenses/%{name} 43 | 44 | %post 45 | /usr/bin/update-desktop-database 46 | /usr/bin/gtk-update-icon-cache 47 | 48 | %postun 49 | /usr/bin/update-desktop-database 50 | /usr/bin/gtk-update-icon-cache 51 | 52 | %files 53 | %license %{_datadir}/licenses/%{name}/license 54 | %{_libdir}/%{name}/ 55 | %{_bindir}/%{name} 56 | %{_datadir}/applications/caprine.desktop 57 | %{_datadir}/pixmaps/%{name}.png 58 | 59 | %changelog 60 | * Wed Apr 3 2024 dusansimic - 2.60.1-1 61 | - Code refactoring 62 | * Tue Feb 20 2024 dusansimic - 2.59.3-1 63 | - Fix blank window 64 | - Fix try icon 65 | * Mon Feb 19 2024 dusansimic - 2.59.2-1 66 | - Hidden dialog issue Fix 67 | - Update Messenger for Mac/Windows selectors 68 | * Wed Oct 11 2023 dusansimic - 2.59.1-1 69 | - Release 2.59.1 70 | * Wed Sep 27 2023 dusansimic - 2.59.0-1 71 | - Release 2.59.0 72 | * Mon Sep 25 2023 dusansimic - 2.58.3-1 73 | - Release 2.58.3 74 | * Mon Sep 25 2023 dusansimic - 2.58.2-1 75 | - Release 2.58.2 76 | * Tue Sep 5 2023 dusansimic - 2.58.1-1 77 | - Release 2.58.1 78 | * Wed Jul 26 2023 dusansimic - 2.58.0-1 79 | - Release 2.58.0 80 | * Sat May 6 2023 dusansimic - 2.57.4-1 81 | - Release 2.57.4 82 | * Sun Apr 30 2023 dusansimic - 2.57.3-1 83 | - Release 2.57.3 84 | * Sat Apr 29 2023 dusansimic - 2.57.2-1 85 | - Release 2.57.2 86 | * Mon Apr 17 2023 dusansimic - 2.57.1-1 87 | - Release 2.57.1 88 | * Wed Nov 16 2022 dusansimic - 2.57.0-1 89 | - Release 2.57.0 90 | * Mon Aug 22 2022 dusansimic - 2.56.1-1 91 | - Release 2.56.1 92 | * Thu Aug 18 2022 dusansimic - 2.56.0-1 93 | - Release 2.56.0 94 | * Thu Jun 16 2022 dusansimic - 2.55.7-1 95 | - Release 2.55.7 96 | * Mon Jun 13 2022 dusansimic - 2.55.6-1 97 | - Release 2.55.6 98 | * Mon May 16 2022 dusansimic - 2.55.5-1 99 | - Release 2.55.5 100 | * Sun Mar 20 2022 dusansimic - 2.55.3-1 101 | - Release 2.55.3 102 | * Thu Dec 9 2021 dusansimic - 2.55.2-1 103 | - Release 2.55.2 104 | * Thu Dec 2 2021 dusansimic - 2.55.1-1 105 | - Release 2.55.1 106 | * Thu Oct 28 2021 dusansimic - 2.55.0-1 107 | - Release 2.55.0 108 | * Fri Aug 13 2021 dusansimic - 2.54.1-1 109 | - Release 2.54.1 110 | * Thu Jul 29 2021 dusansimic - 2.54.0-1 111 | - Release 2.54.0 112 | * Sat May 8 2021 dusansimic - 2.53.0-1 113 | - Release 2.53.0 114 | * Mon Apr 26 2021 dusansimic - 2.52.4-1 115 | - Release 2.52.4 116 | - Removed dependency desktop-file-utils and gtk-update-icon-cache 117 | * Fri Apr 9 2021 dusansimic - 2.52.3-1 118 | - Release 2.52.3 119 | - Some minor updates to spec file and adding license file to installation 120 | * Thu Mar 25 2021 dusansimic - 2.52.2-1 121 | - Release 2.52.2 122 | -------------------------------------------------------------------------------- /patches/electron-debug++electron-is-dev+1.2.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/electron-debug/node_modules/electron-is-dev/index.js b/node_modules/electron-debug/node_modules/electron-is-dev/index.js 2 | index 3b3fbc5..042a8a5 100644 3 | --- a/node_modules/electron-debug/node_modules/electron-is-dev/index.js 4 | +++ b/node_modules/electron-debug/node_modules/electron-is-dev/index.js 5 | @@ -5,7 +5,7 @@ if (typeof electron === 'string') { 6 | throw new TypeError('Not running in an Electron environment!'); 7 | } 8 | 9 | -const app = electron.app || electron.remote.app; 10 | +const app = electron.app || require('@electron/remote'); 11 | 12 | const isEnvSet = 'ELECTRON_IS_DEV' in process.env; 13 | const getFromEnv = parseInt(process.env.ELECTRON_IS_DEV, 10) === 1; 14 | -------------------------------------------------------------------------------- /patches/electron-util++electron-is-dev+1.2.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/electron-util/node_modules/electron-is-dev/index.js b/node_modules/electron-util/node_modules/electron-is-dev/index.js 2 | index 3b3fbc5..042a8a5 100644 3 | --- a/node_modules/electron-util/node_modules/electron-is-dev/index.js 4 | +++ b/node_modules/electron-util/node_modules/electron-is-dev/index.js 5 | @@ -5,7 +5,7 @@ if (typeof electron === 'string') { 6 | throw new TypeError('Not running in an Electron environment!'); 7 | } 8 | 9 | -const app = electron.app || electron.remote.app; 10 | +const app = electron.app || require('@electron/remote'); 11 | 12 | const isEnvSet = 'ELECTRON_IS_DEV' in process.env; 13 | const getFromEnv = parseInt(process.env.ELECTRON_IS_DEV, 10) === 1; 14 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 | 7 |

Caprine

8 |

9 | Elegant Facebook Messenger desktop app 10 |

11 |
12 |
13 |

14 | Caprine is an unofficial and privacy-focused Facebook Messenger app with many useful features. 15 |

16 | 17 | Caprine is feature complete. However, we welcome contributions for improvements and bug fixes. 18 | 19 |
20 | 21 | Website 22 | 23 |
24 | 25 | 26 | 27 |
28 | 29 | ## Highlights 30 | 31 | - [Dark theme](#dark-mode) 32 | - [Vibrant theme](#vibrancy-macos-only)\* 33 | - [Privacy-focused](#privacy) 34 | - [Keyboard shortcuts](#keyboard-shortcuts) 35 | - [Menu bar mode](#menu-bar-mode-macos-only-)\* 36 | - [Work Chat support](#work-chat-support) 37 | - [Code blocks](#code-blocks) 38 | - [Touch Bar support](#touch-bar-support-macos-only)\* 39 | - [Custom styles](#custom-styles) 40 | - Cross-platform 41 | - Silent auto-updates 42 | - Custom text size 43 | - Emoji style setting 44 | - Respects Do Not Disturb\* 45 | 46 | \*macOS only 47 | 48 | ## Install 49 | 50 | *macOS 10.12+ (Intel and Apple Silicon), Linux (x64 and arm64), and Windows 10+ (64-bit) are supported.* 51 | 52 | Download the latest version on the [website](https://github.com/sindresorhus/caprine) or below. 53 | 54 | ### macOS 55 | 56 | [**Download**](https://github.com/sindresorhus/caprine/releases/latest) the `.dmg` file. 57 | 58 | Or with [Homebrew](https://brew.sh): `$ brew install caprine` 59 | 60 | ### Linux 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 137 | 138 |
DistributionRepositoryAutomatic UpdatesMaintainerHow to install
Arch LinuxCommunity✔️Frederik Schwanpacman -S caprine
Debian / Ubuntu (manually)GitHubOfficial 81 | Download the .deb file 82 |
Debian / Ubuntu (deb-get)GitHub✔️Official 90 | Follow the instructions below 91 |
Debian / Ubuntu (APT)Gemfury✔️Lefteris Garyfalakis 99 | Follow the instructions below 100 |
RHEL / Fedora / openSUSECopr✔️Dušan Simić 108 | Follow the instructions below 109 |
AppImageGitHub✔️Official 117 | Follow the instructions below 118 |
FlatpakFlathub✔️Dušan Simić 126 | Visit Flathub and follow the instructions 127 |
SnapSnapcraft✔️Official 135 | Visit Snapcraft and follow the instructions 136 |
139 | 140 | #### Installation using deb-get: 141 | 142 | * Download and install [deb-get](https://github.com/wimpysworld/deb-get). 143 | * Run `deb-get install caprine`. 144 | 145 | Note: deb-get is 3rd party software, not to be associated with apt-get. 146 | 147 | #### APT repository (Gemfury): 148 | 149 | Run the following command to add it: 150 | 151 | ```sh 152 | wget -q -O- https://raw.githubusercontent.com/sindresorhus/caprine/main/packages/deb/addRepo.sh | sudo bash 153 | ``` 154 | 155 | Alternatively (for advanced users): 156 | ```sh 157 | # Add the repository 158 | echo "deb [trusted=yes] https://apt.fury.io/lefterisgar/ * *" > \ 159 | /etc/apt/sources.list.d/caprine.list 160 | 161 | # Update the package indexes 162 | sudo apt update 163 | 164 | # Install Caprine 165 | sudo apt install caprine 166 | ``` 167 | 168 | 169 | #### Copr: 170 | 171 | For Fedora / RHEL: 172 | 173 | ```sh 174 | sudo dnf copr enable dusansimic/caprine 175 | sudo dnf install caprine 176 | ``` 177 | 178 | For openSUSE: 179 | - Create a new file in `/etc/zypp/repos.d/caprine.repo`. 180 | - Copy the contents of [this file](https://copr.fedorainfracloud.org/coprs/dusansimic/caprine/repo/opensuse-tumbleweed/dusansimic-caprine-opensuse-tumbleweed.repo) and paste them into the file you just created. 181 | 182 | Alternatively use the following one-liner: 183 | ```sh 184 | curl -s https://copr.fedorainfracloud.org/coprs/dusansimic/caprine/repo/opensuse-tumbleweed/dusansimic-caprine-opensuse-tumbleweed.repo | sudo tee /etc/zypp/repos.d/caprine.repo 185 | ``` 186 | 187 | #### AppImage: 188 | 189 | [Download](https://github.com/sindresorhus/caprine/releases/latest) the `.AppImage` file. 190 | 191 | Make it [executable](https://discourse.appimage.org/t/how-to-run-an-appimage/80): 192 | 193 | ```sh 194 | chmod +x Caprine-2.xx.x.AppImage 195 | ``` 196 | 197 | Then run it! 198 | 199 | #### About immutable Linux distributions: 200 | [Fedora Silverblue](https://silverblue.fedoraproject.org), [Fedora Kinoite](https://kinoite.fedoraproject.org), [EndlessOS](https://endlessos.com), [CarbonOS](https://carbon.sh) and other immutable distributions only support Flatpak and/or AppImage.* 201 | 202 | *Note: On some distributions Flatpak must be [pre-configured manually](https://flatpak.org/setup).* 203 | 204 | ### Windows 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 |
MethodRepositoryAutomatic UpdatesMaintainerHow to install
ManuallyGitHubOfficial 218 | Download the .exe file 219 |
ChocolateyCommunity✔️Michael Quevillonchoco install caprine
229 | 230 | *For taskbar notification badges to work on Windows 10, you'll need to [enable them in Taskbar Settings](https://www.tenforums.com/tutorials/48186-taskbar-buttons-hide-show-badges-windows-10-a.html).* 231 | 232 | ## Features 233 | 234 | ### Dark mode 235 | 236 | You can toggle dark mode in the `View` menu or with Command d / Control d. 237 | 238 | 239 | 240 | ### Hide Names and Avatars 241 | 242 | You can prevent others from looking at who you're chatting with by enabling the “Hide Names and Avatars” feature in the “View” menu or with Command/Control Shift n. 243 | 244 | ### Vibrancy *(macOS only)* 245 | 246 | On *macOS*, you can toggle the window vibrancy effect in the `View` menu. 247 | 248 | 249 | 250 | ### Privacy 251 | 252 | 253 | 254 | You can choose to prevent people from knowing when you have seen a message and when you are currently typing. These settings are available under the `Caprine`/`File` menu. 255 | 256 | ### Mute desktop notifications *(macOS only)* 257 | 258 | You can quickly disable receiving notifications from the `Caprine`/`File` menu or the Dock on macOS. 259 | 260 | ### Hide notification message preview 261 | 262 |
263 | 264 |
265 | 266 |
267 | 268 | You can toggle the `Show Message Preview in Notification` setting in the `Caprine`/`File` menu. 269 | 270 | ### Prevents link tracking 271 | 272 | Links that you click on will not be tracked by Facebook. 273 | 274 | ### Jump to conversation hotkey 275 | 276 | You can switch conversations similar to how you switch browser tabs: Command/Control n (where `n` is `1` through `9`). 277 | 278 | ### Compact mode 279 | 280 | The interface adapts when resized to a small size. 281 | 282 |
283 | 284 | ### Desktop notifications 285 | 286 | Desktop notifications can be turned on in `Preferences`. 287 | 288 |
289 | 290 | ### Always on Top 291 | 292 | You can toggle whether Caprine stays on top of other windows in the `Window`/`View` menu or with Command/Control Shift t. 293 | 294 | ### Work Chat support 295 | 296 | Support for Work Chat: Messenger for [Workplace](https://www.facebook.com/workplace). You can switch to it in the `Caprine`/`File` menu. 297 | 298 |
299 | 300 | ### Code blocks 301 | 302 | You can send code blocks by using [Markdown syntax](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet#code). 303 | 304 |
305 |
306 | 307 | ### Background behavior 308 | 309 | When closing the window, the app will by default continue running in the background, in the dock on macOS and the tray on Linux/Windows. Right-click the dock/tray icon and choose `Quit` to completely quit the app. On macOS, click the dock icon to show the window. On Linux, right-click the tray icon and choose `Toggle` to toggle the window. On Windows, click the tray icon to toggle the window. 310 | 311 | Note that you can change the behavior of Caprine so that the app closes when the window is closed. For this, you'll need to go to the settings and click on `Quit on Window Close`. 312 | 313 | ### Quick access to conversations from the Dock menu *(macOS only)* 314 | 315 | 316 | 317 | ### Touch Bar support *(macOS only)* 318 | 319 | 320 | 321 | ### Custom languages for spell-check *(Not for macOS)* 322 | 323 | Users can select supported languages from `Conversation` → `Spell Checker Language`. 324 | 325 | macOS detects the language automatically. 326 | 327 | ### Custom styles 328 | 329 | Advanced users can modify the colors/styles of Caprine. Click the menu item `Caprine`/`File` → `Caprine Settings` → `Advanced` → `Custom Styles` and a CSS file will open up in your default editor. 330 | 331 | ### Menu Bar Mode *(macOS only)* 332 | 333 | 334 | 335 | You can enable `Show Menu Bar Icon` in the `Caprine Preferences` menu to have a Caprine icon in the menu bar. The icon will indicate when you have unread notifications and you can click it to toggle the Caprine window. You can also toggle the Caprine window with the global shortcut Command Shift y. 336 | 337 | You can also remove Caprine from the Dock and task switcher by clicking `Hide Dock Icon` menu item from the menu bar icon. There will then no longer be any menus for the window, but you can access those from the `Menu` item in the menu bar icon menu. 338 | 339 | ### Keyboard shortcuts 340 | 341 | Description | Keys 342 | -----------------------| ----------------------- 343 | New conversation | Command/Control n 344 | Search conversations | Command/Control k 345 | Toggle "Dark mode" | Command/Control d 346 | Hide Names and Avatars | Command/Control Shift n 347 | Next conversation | Command/Control ] or Control Tab 348 | Previous conversation | Command/Control [ or Control Shift Tab 349 | Jump to conversation | Command/Control 19 350 | Insert GIF | Command/Control g 351 | Insert sticker | Command/Control s 352 | Insert emoji | Command/Control e 353 | Attach files | Command/Control t 354 | Focus text input | Command/Control i 355 | Search in conversation | Command/Control f 356 | Mute conversation | Command/Control Shift m 357 | Hide conversation | Command/Control Shift h 358 | Delete conversation | Command/Control Shift d 359 | Toggle "Always on Top" | Command/Control Shift t 360 | Toggle window menu | Alt *(Windows/Linux only)* 361 | Toggle main window | Command Shift y *(macOS only)* 362 | Toggle sidebar | Command/Control Shift s 363 | Switch to Messenger | Command/Control Shift 1 364 | Switch to Workchat | Command/Control Shift 2 365 | Preferences | Command/Control , 366 | 367 | ###### Tip 368 | 369 | On macOS, you can [change these in the System Preferences](https://www.intego.com/mac-security-blog/how-to-make-custom-keyboard-shortcuts-for-any-macos-menu-items-and-to-launch-your-favorite-apps/) and you can even add your own keyboard shortcuts for menu items without a predefined keyboard shortcut. 370 | 371 | ## FAQ 372 | 373 | #### Can I contribute localizations? 374 | 375 | The main app interface is already localized by Facebook. The app menus are not localized, and we're not interested in localizing those. 376 | 377 | --- 378 | 379 | ## Dev 380 | 381 | Built with [Electron](https://electronjs.org). 382 | 383 | ### Run 384 | 385 | ```sh 386 | npm install && npm start 387 | ``` 388 | 389 | ### Build 390 | 391 | See the [`electron-builder` docs](https://www.electron.build/multi-platform-build). 392 | 393 | ### Publish 394 | 395 | ```sh 396 | npm run release 397 | ``` 398 | 399 | Then edit the automatically created GitHub Releases draft and publish. 400 | 401 | ## Maintainers 402 | 403 | - [Dušan Simić](https://github.com/dusansimic) 404 | - [Lefteris Garyfalakis](https://github.com/lefterisgar) 405 | - [Michael Quevillon](https://github.com/mquevill) 406 | - [Nikolas Spiridakis](https://github.com/1nikolas) 407 | 408 | **Former** 409 | 410 | - [Jarek Radosz](https://github.com/CvX) 411 | 412 | ## Links 413 | 414 | - [Product Hunt post](https://www.producthunt.com/posts/caprine-2) 415 | 416 | ## Press 417 | 418 | - [The Essential Windows Apps for 2018 - Lifehacker](https://lifehacker.com/lifehacker-pack-for-windows-our-list-of-the-essential-1828117805) 419 | - [Caprine review: Customize Facebook Messenger on Windows 10 - Windows Central](https://www.windowscentral.com/caprine-review-customizing-facebook-messenger-windows-10) 420 | 421 | ## Disclaimer 422 | 423 | Caprine is a third-party app and is not affiliated with Facebook. 424 | -------------------------------------------------------------------------------- /source/autoplay.ts: -------------------------------------------------------------------------------- 1 | import {ipcRenderer as ipc} from 'electron-better-ipc'; 2 | import selectors from './browser/selectors'; 3 | 4 | const conversationId = 'conversationWindow'; 5 | const disabledVideoId = 'disabled_autoplay'; 6 | 7 | export async function toggleVideoAutoplay(): Promise { 8 | const autoplayVideos = await ipc.callMain('get-config-autoplayVideos'); 9 | if (autoplayVideos) { 10 | // Stop the observers 11 | conversationDivObserver.disconnect(); 12 | videoObserver.disconnect(); 13 | 14 | // Revert previous changes 15 | enableVideoAutoplay(); 16 | } else { 17 | // Start the observer 18 | startConversationWindowObserver(); 19 | 20 | // Trigger once manually before observers kick in 21 | disableVideoAutoplay(getVideos()); 22 | } 23 | } 24 | 25 | // Hold reference to videos the user has started playing 26 | // Enables us to check if the video is autoplaying, for example, when changing conversation 27 | const playedVideos: HTMLVideoElement[] = []; 28 | 29 | function disableVideoAutoplay(videos: NodeListOf): void { 30 | for (const video of videos) { 31 | // Don't disable currently playing videos 32 | if (playedVideos.includes(video)) { 33 | continue; 34 | } 35 | 36 | const firstParent = video.parentElement!; 37 | 38 | // Video parent element which has a snapshot of the video as a background image 39 | const parentWithBackground = video.parentElement!.parentElement!.parentElement!; 40 | 41 | // Hold reference to the background parent so we can revert our changes 42 | const parentWithBackgroundParent = parentWithBackground.parentElement!; 43 | 44 | // Reference to the original play icon on top of the video 45 | const playIcon = video.nextElementSibling!.nextElementSibling! as HTMLElement; 46 | // If the video is playing, the icon is hidden 47 | playIcon.classList.remove('hidden_elem'); 48 | 49 | // Set the `id` so we can easily trigger a click-event when reverting changes 50 | playIcon.setAttribute('id', disabledVideoId); 51 | 52 | const { 53 | style: {width, height}, 54 | } = firstParent; 55 | 56 | const style = parentWithBackground.style || window.getComputedStyle(parentWithBackground); 57 | const backgroundImageSource = style.backgroundImage.slice(4, -1).replaceAll(/"/, ''); 58 | 59 | // Create the image to replace the video as a placeholder 60 | const image = document.createElement('img'); 61 | image.setAttribute('src', backgroundImageSource); 62 | image.classList.add('disabledAutoPlayImgTopRadius'); 63 | 64 | // If it's a video without a source title bar at the bottom, 65 | // round the bottom part of the video 66 | if (parentWithBackgroundParent.childElementCount === 1) { 67 | image.classList.add('disabledAutoPlayImgBottomRadius'); 68 | } 69 | 70 | image.setAttribute('height', height); 71 | image.setAttribute('width', width); 72 | 73 | // Create a separate instance of the play icon 74 | // Clone the existing icon to get the original events 75 | // Without creating a new icon, Messenger auto-hides the icon when scrolled to the video 76 | const copiedPlayIcon = playIcon.cloneNode(true) as HTMLElement; 77 | 78 | // Remove the image and the new play icon and append the original divs 79 | // We can enable autoplay again by triggering this event 80 | copiedPlayIcon.addEventListener('play', () => { 81 | image.remove(); 82 | copiedPlayIcon.remove(); 83 | parentWithBackgroundParent.prepend(parentWithBackground); 84 | }); 85 | 86 | // Separate handler for `click` so we know if it was the user who played the video 87 | copiedPlayIcon.addEventListener('click', () => { 88 | playedVideos.push(video); 89 | const event = new Event('play'); 90 | copiedPlayIcon.dispatchEvent(event); 91 | // Sometimes the video doesn't start playing even though we trigger the click event 92 | // As a workaround, check if the video didn't start playing and manually trigger 93 | // the click event 94 | setTimeout(() => { 95 | if (video.paused) { 96 | playIcon.click(); 97 | } 98 | }, 50); 99 | }); 100 | 101 | parentWithBackgroundParent.prepend(image); 102 | parentWithBackgroundParent.prepend(copiedPlayIcon); 103 | parentWithBackground.remove(); 104 | } 105 | } 106 | 107 | // If we previously disabled autoplay on videos, 108 | // trigger the `copiedPlayIcon` click event to revert changes 109 | function enableVideoAutoplay(): void { 110 | const playIcons = document.querySelectorAll(`#${disabledVideoId}`); 111 | for (const icon of playIcons) { 112 | const event = new Event('play'); 113 | icon.dispatchEvent(event); 114 | } 115 | } 116 | 117 | function getVideos(): NodeListOf { 118 | return document.querySelectorAll('video'); 119 | } 120 | 121 | function startConversationWindowObserver(): void { 122 | conversationDivObserver.observe(document.documentElement, { 123 | childList: true, 124 | subtree: true, 125 | }); 126 | } 127 | 128 | function startVideoObserver(element: Element): void { 129 | videoObserver.observe(element, { 130 | childList: true, 131 | subtree: true, 132 | }); 133 | } 134 | 135 | // A way to hold reference to conversation part of the document 136 | // Used to refresh `videoObserver` after the conversation reference is lost 137 | let conversationWindow: Element; 138 | const conversationDivObserver = new MutationObserver(_ => { 139 | let conversation = document.querySelector(`#${conversationId}`); 140 | 141 | // Fetch it using `querySelector` if no luck with the `conversationId` 142 | conversation ||= document.querySelector(selectors.conversationSelector); 143 | 144 | // If we have a new reference 145 | if (conversation && conversationWindow !== conversation) { 146 | // Add `conversationId` so we know when we've lost the reference to 147 | // the `conversationWindow` and we can restart the video observer 148 | conversation.id = conversationId; 149 | conversationWindow = conversation; 150 | startVideoObserver(conversationWindow); 151 | } 152 | }); 153 | 154 | // Reference to mutation observer 155 | // Only active if the user has set option to disable video autoplay 156 | const videoObserver = new MutationObserver(_ => { 157 | // Select by tag instead of iterating over mutations which is more performant 158 | const videos = getVideos(); 159 | // If videos were added disable autoplay 160 | if (videos.length > 0) { 161 | disableVideoAutoplay(videos); 162 | } 163 | }); 164 | -------------------------------------------------------------------------------- /source/browser-call.ts: -------------------------------------------------------------------------------- 1 | import elementReady from 'element-ready'; 2 | 3 | (async () => { 4 | const startCallButton = (await elementReady('._3quh._30yy._2t_', { 5 | stopOnDomReady: false, 6 | }))!; 7 | 8 | startCallButton.click(); 9 | })(); 10 | -------------------------------------------------------------------------------- /source/browser.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import {ipcRenderer as ipc} from 'electron-better-ipc'; 3 | import {is} from 'electron-util'; 4 | import elementReady from 'element-ready'; 5 | import {nativeTheme} from '@electron/remote'; 6 | import selectors from './browser/selectors'; 7 | import {toggleVideoAutoplay} from './autoplay'; 8 | import {sendConversationList} from './browser/conversation-list'; 9 | import {IToggleSounds} from './types'; 10 | 11 | async function withMenu( 12 | menuButtonElement: HTMLElement, 13 | callback: () => Promise | void, 14 | ): Promise { 15 | const {classList} = document.documentElement; 16 | 17 | // Prevent the dropdown menu from displaying 18 | classList.add('hide-dropdowns'); 19 | 20 | // Click the menu button 21 | menuButtonElement.click(); 22 | 23 | // Wait for the menu to close before removing the 'hide-dropdowns' class 24 | await elementReady('.x78zum5.xdt5ytf.x1n2onr6.xat3117.xxzkxad > div:nth-child(2) > div', {stopOnDomReady: false}); 25 | const menuLayer = document.querySelector('.x78zum5.xdt5ytf.x1n2onr6.xat3117.xxzkxad > div:nth-child(2) > div'); 26 | 27 | if (menuLayer) { 28 | const observer = new MutationObserver(() => { 29 | if (!menuLayer.hasChildNodes()) { 30 | classList.remove('hide-dropdowns'); 31 | observer.disconnect(); 32 | } 33 | }); 34 | observer.observe(menuLayer, {childList: true}); 35 | } else { 36 | // Fallback in case .uiContextualLayerPositioner is missing 37 | classList.remove('hide-dropdowns'); 38 | } 39 | 40 | await callback(); 41 | } 42 | 43 | async function isNewSidebar(): Promise { 44 | // TODO: stopOnDomReady might not be needed 45 | await elementReady(selectors.leftSidebar, {stopOnDomReady: false}); 46 | 47 | const sidebars = document.querySelectorAll(selectors.leftSidebar); 48 | 49 | return sidebars.length === 2; 50 | } 51 | 52 | async function withSettingsMenu(callback: () => Promise | void): Promise { 53 | // Wait for navigation pane buttons to show up 54 | const settingsMenu = await elementReady(selectors.userMenuNewSidebar, {stopOnDomReady: false}); 55 | 56 | await withMenu(settingsMenu as HTMLElement, callback); 57 | } 58 | 59 | async function selectMenuItem(itemNumber: number): Promise { 60 | let selector; 61 | 62 | // Wait for menu to show up 63 | await elementReady(selectors.conversationMenuSelectorNewDesign, {stopOnDomReady: false}); 64 | 65 | const items = document.querySelectorAll( 66 | `${selectors.conversationMenuSelectorNewDesign} [role=menuitem]`, 67 | ); 68 | 69 | // Negative items will select from the end 70 | if (itemNumber < 0) { 71 | selector = -itemNumber <= items.length ? items[items.length + itemNumber] : null; 72 | } else { 73 | selector = itemNumber <= items.length ? items[itemNumber - 1] : null; 74 | } 75 | 76 | if (selector) { 77 | selector.click(); 78 | } 79 | } 80 | 81 | async function selectOtherListViews(itemNumber: number): Promise { 82 | // In case one of other views is shown 83 | clickBackButton(); 84 | 85 | const newSidebar = await isNewSidebar(); 86 | 87 | if (newSidebar) { 88 | const items = document.querySelectorAll( 89 | `${selectors.viewsMenu} span > a`, 90 | ); 91 | 92 | const selector = itemNumber <= items.length ? items[itemNumber - 1] : null; 93 | 94 | if (selector) { 95 | selector.click(); 96 | } 97 | } else { 98 | await withSettingsMenu(() => { 99 | selectMenuItem(itemNumber); 100 | }); 101 | } 102 | } 103 | 104 | function clickBackButton(): void { 105 | const backButton = document.querySelector('._30yy._2oc9'); 106 | 107 | if (backButton) { 108 | backButton.click(); 109 | } 110 | } 111 | 112 | ipc.answerMain('show-preferences', async () => { 113 | if (isPreferencesOpen()) { 114 | return; 115 | } 116 | 117 | await openPreferences(); 118 | }); 119 | 120 | ipc.answerMain('new-conversation', async () => { 121 | document.querySelector('[href="/new/"]')!.click(); 122 | }); 123 | 124 | ipc.answerMain('new-room', async () => { 125 | document.querySelector('.x16n37ib .x1i10hfl.x6umtig.x1b1mbwd.xaqea5y.xav7gou.x1ypdohk.xe8uvvx.xdj266r.x11i5rnm.xat24cr.x1mh8g0r.x16tdsg8.x1hl2dhg.xggy1nq.x87ps6o.x1lku1pv.x1a2a7pz.x6s0dn4.x14yjl9h.xudhj91.x18nykt9.xww2gxu.x972fbf.xcfux6l.x1qhh985.xm0m39n.x9f619.x78zum5.xl56j7k.xexx8yu.x4uap5.x18d9i69.xkhd6sd.x1n2onr6.xc9qbxq.x14qfxbe.x1qhmfi1')!.click(); 126 | }); 127 | 128 | ipc.answerMain('log-out', async () => { 129 | const useWorkChat = await ipc.callMain('get-config-useWorkChat'); 130 | if (useWorkChat) { 131 | document.querySelector('._5lxs._3qct._p')!.click(); 132 | 133 | // Menu creation is slow 134 | setTimeout(() => { 135 | const nodes = document.querySelectorAll( 136 | '._54nq._9jo._558b._2n_z li:last-child a', 137 | ); 138 | 139 | nodes[nodes.length - 1].click(); 140 | }, 250); 141 | } else { 142 | await withSettingsMenu(() => { 143 | selectMenuItem(-1); 144 | }); 145 | } 146 | }); 147 | 148 | ipc.answerMain('find', () => { 149 | document.querySelector('[type="search"]')!.focus(); 150 | }); 151 | 152 | async function openSearchInConversation() { 153 | const mainView = document.querySelector('.x9f619.x1ja2u2z.x78zum5.x1n2onr6.x1r8uery.x1iyjqo2.xs83m0k.xeuugli.x1qughib.x1qjc9v5.xozqiw3.x1q0g3np.xexx8yu.x85a59c')!; 154 | const rightSidebarIsClosed = Boolean(mainView.querySelector(':scope > div:only-child')); 155 | 156 | if (rightSidebarIsClosed) { 157 | document.querySelector(selectors.rightSidebarMenu)?.click(); 158 | } 159 | 160 | await elementReady(selectors.rightSidebarButtons, {stopOnDomReady: false}); 161 | const buttonList = document.querySelectorAll(selectors.rightSidebarButtons); 162 | 163 | // Search in conversation is the last button 164 | buttonList[buttonList.length - 1].click(); 165 | } 166 | 167 | ipc.answerMain('search', () => { 168 | openSearchInConversation(); 169 | }); 170 | 171 | ipc.answerMain('insert-gif', () => { 172 | document.querySelector('.x1n2onr6.x1iyjqo2.xw2csxc > div:nth-child(3) > span > div')!.click(); 173 | }); 174 | 175 | ipc.answerMain('insert-emoji', async () => { 176 | document.querySelector('.x1n2onr6.x1iyjqo2.xw2csxc > div:nth-child(5) > span > div')!.click(); 177 | }); 178 | 179 | ipc.answerMain('insert-sticker', () => { 180 | document.querySelector('.x1n2onr6.x1iyjqo2.xw2csxc > div:nth-child(2) > span > div')!.click(); 181 | }); 182 | 183 | ipc.answerMain('attach-files', () => { 184 | document.querySelector('.x1n2onr6.x1iyjqo2.xw2csxc > div:nth-child(1) > span > div')!.click(); 185 | }); 186 | 187 | ipc.answerMain('focus-text-input', () => { 188 | document.querySelector('[role=textbox][contenteditable=true]')!.focus(); 189 | }); 190 | 191 | ipc.answerMain('next-conversation', nextConversation); 192 | 193 | ipc.answerMain('previous-conversation', previousConversation); 194 | 195 | ipc.answerMain('mute-conversation', async () => { 196 | await openMuteModal(); 197 | }); 198 | 199 | ipc.answerMain('delete-conversation', async () => { 200 | const index = selectedConversationIndex(); 201 | 202 | if (index !== -1) { 203 | await deleteSelectedConversation(); 204 | 205 | const key = index + 1; 206 | await jumpToConversation(key); 207 | } 208 | }); 209 | 210 | ipc.answerMain('archive-conversation', async () => { 211 | const index = selectedConversationIndex(); 212 | 213 | if (index !== -1) { 214 | await archiveSelectedConversation(); 215 | 216 | const key = index + 1; 217 | await jumpToConversation(key); 218 | } 219 | }); 220 | 221 | async function openHiddenPreferences(): Promise { 222 | if (!isPreferencesOpen()) { 223 | document.documentElement.classList.add('hide-preferences-window'); 224 | 225 | await openPreferences(); 226 | 227 | return true; 228 | } 229 | 230 | return false; 231 | } 232 | 233 | async function toggleSounds({checked}: IToggleSounds): Promise { 234 | const shouldClosePreferences = await openHiddenPreferences(); 235 | 236 | const soundsCheckbox = document.querySelector(`${selectors.preferencesSelector} ${selectors.messengerSoundsSelector}`)!; 237 | if (checked === undefined || checked !== soundsCheckbox.checked) { 238 | soundsCheckbox.click(); 239 | } 240 | 241 | if (shouldClosePreferences) { 242 | await closePreferences(); 243 | } 244 | } 245 | 246 | ipc.answerMain('toggle-sounds', toggleSounds); 247 | 248 | ipc.answerMain('toggle-mute-notifications', async () => { 249 | const shouldClosePreferences = await openHiddenPreferences(); 250 | 251 | const notificationCheckbox = document.querySelector( 252 | selectors.notificationCheckbox, 253 | )!; 254 | 255 | if (shouldClosePreferences) { 256 | await closePreferences(); 257 | } 258 | 259 | // TODO: Fix notifications 260 | if (notificationCheckbox === null) { 261 | return false; 262 | } 263 | 264 | return !notificationCheckbox.checked; 265 | }); 266 | 267 | ipc.answerMain('toggle-message-buttons', async () => { 268 | const showMessageButtons = await ipc.callMain('get-config-showMessageButtons'); 269 | document.body.classList.toggle('show-message-buttons', !showMessageButtons); 270 | }); 271 | 272 | ipc.answerMain('show-chats-view', async () => { 273 | await selectOtherListViews(1); 274 | }); 275 | 276 | ipc.answerMain('show-marketplace-view', async () => { 277 | await selectOtherListViews(2); 278 | }); 279 | 280 | ipc.answerMain('show-requests-view', async () => { 281 | await selectOtherListViews(3); 282 | }); 283 | 284 | ipc.answerMain('show-archive-view', async () => { 285 | await selectOtherListViews(4); 286 | }); 287 | 288 | ipc.answerMain('toggle-video-autoplay', () => { 289 | toggleVideoAutoplay(); 290 | }); 291 | 292 | ipc.answerMain('reload', () => { 293 | location.reload(); 294 | }); 295 | 296 | async function setTheme(): Promise { 297 | type ThemeSource = typeof nativeTheme.themeSource; 298 | const theme = await ipc.callMain('get-config-theme'); 299 | nativeTheme.themeSource = theme; 300 | setThemeElement(document.documentElement); 301 | updateVibrancy(); 302 | } 303 | 304 | function setThemeElement(element: HTMLElement): void { 305 | const useDarkColors = Boolean(nativeTheme.shouldUseDarkColors); 306 | element.classList.toggle('dark-mode', useDarkColors); 307 | element.classList.toggle('light-mode', !useDarkColors); 308 | element.classList.toggle('__fb-dark-mode', useDarkColors); 309 | element.classList.toggle('__fb-light-mode', !useDarkColors); 310 | removeThemeClasses(useDarkColors); 311 | } 312 | 313 | function removeThemeClasses(useDarkColors: boolean): void { 314 | // TODO: Workaround for Facebooks buggy frontend 315 | // The ui sometimes hardcodes ligth mode classes in the ui. This removes them so the class 316 | // in the root element would be used. 317 | const className = useDarkColors ? '__fb-light-mode' : '__fb-dark-mode'; 318 | for (const element of document.querySelectorAll(`.${className}`)) { 319 | element.classList.remove(className); 320 | } 321 | } 322 | 323 | async function observeTheme(): Promise { 324 | /* Main document's class list */ 325 | const observer = new MutationObserver((records: MutationRecord[]) => { 326 | // Find records that had class attribute changed 327 | const classRecords = records.filter(record => record.type === 'attributes' && record.attributeName === 'class'); 328 | // Check if dark mode classes exists 329 | const isDark = classRecords.some(record => { 330 | const {classList} = (record.target as HTMLElement); 331 | return classList.contains('dark-mode') && classList.contains('__fb-dark-mode'); 332 | }); 333 | // If config and class list don't match, update class list 334 | if (nativeTheme.shouldUseDarkColors !== isDark) { 335 | setTheme(); 336 | } 337 | }); 338 | 339 | observer.observe(document.documentElement, {attributes: true, attributeFilter: ['class']}); 340 | 341 | /* Added nodes (dialogs, etc.) */ 342 | const observerNew = new MutationObserver((records: MutationRecord[]) => { 343 | const nodeRecords = records.filter(record => record.addedNodes.length > 0); 344 | for (const nodeRecord of nodeRecords) { 345 | for (const newNode of nodeRecord.addedNodes) { 346 | const {classList} = (newNode as HTMLElement); 347 | const isLight = classList.contains('light-mode') || classList.contains('__fb-light-mode'); 348 | if (nativeTheme.shouldUseDarkColors === isLight) { 349 | setThemeElement(newNode as HTMLElement); 350 | } 351 | } 352 | } 353 | }); 354 | 355 | /* Observe only elements where new nodes may need dark mode */ 356 | const menuElements = await elementReady('.j83agx80.cbu4d94t.l9j0dhe7.jgljxmt5.be9z9djy > div:nth-of-type(2) > div', {stopOnDomReady: false}); 357 | if (menuElements) { 358 | observerNew.observe(menuElements, {childList: true}); 359 | } 360 | 361 | const modalElements = await elementReady(selectors.preferencesSelector, {stopOnDomReady: false}); 362 | if (modalElements) { 363 | observerNew.observe(modalElements, {childList: true}); 364 | } 365 | } 366 | 367 | async function setPrivateMode(): Promise { 368 | const privateMode = await ipc.callMain('get-config-privateMode'); 369 | document.documentElement.classList.toggle('private-mode', privateMode); 370 | 371 | if (is.macos) { 372 | sendConversationList(); 373 | } 374 | } 375 | 376 | async function updateVibrancy(): Promise { 377 | const {classList} = document.documentElement; 378 | 379 | classList.remove('sidebar-vibrancy', 'full-vibrancy'); 380 | 381 | const vibrancy = await ipc.callMain('get-config-vibrancy'); 382 | 383 | switch (vibrancy) { 384 | case 'sidebar': { 385 | classList.add('sidebar-vibrancy'); 386 | break; 387 | } 388 | 389 | case 'full': { 390 | classList.add('full-vibrancy'); 391 | break; 392 | } 393 | 394 | default: 395 | } 396 | 397 | ipc.callMain('set-vibrancy'); 398 | } 399 | 400 | async function updateSidebar(): Promise { 401 | const {classList} = document.documentElement; 402 | 403 | classList.remove('sidebar-hidden', 'sidebar-force-narrow', 'sidebar-force-wide'); 404 | 405 | const sidebar = await ipc.callMain('get-config-sidebar'); 406 | 407 | switch (sidebar) { 408 | case 'hidden': { 409 | classList.add('sidebar-hidden'); 410 | break; 411 | } 412 | 413 | case 'narrow': { 414 | classList.add('sidebar-force-narrow'); 415 | break; 416 | } 417 | 418 | case 'wide': { 419 | classList.add('sidebar-force-wide'); 420 | break; 421 | } 422 | 423 | default: 424 | } 425 | } 426 | 427 | // TODO: Implement this function 428 | async function updateDoNotDisturb(): Promise { 429 | const shouldClosePreferences = await openHiddenPreferences(); 430 | 431 | if (shouldClosePreferences) { 432 | await closePreferences(); 433 | } 434 | } 435 | 436 | function renderOverlayIcon(messageCount: number): HTMLCanvasElement { 437 | const canvas = document.createElement('canvas'); 438 | canvas.height = 128; 439 | canvas.width = 128; 440 | canvas.style.letterSpacing = '-5px'; 441 | 442 | const context = canvas.getContext('2d')!; 443 | context.fillStyle = '#f42020'; 444 | context.beginPath(); 445 | context.ellipse(64, 64, 64, 64, 0, 0, 2 * Math.PI); 446 | context.fill(); 447 | context.textAlign = 'center'; 448 | context.fillStyle = 'white'; 449 | context.font = '90px sans-serif'; 450 | context.fillText(String(Math.min(99, messageCount)), 64, 96); 451 | 452 | return canvas; 453 | } 454 | 455 | ipc.answerMain('update-sidebar', () => { 456 | updateSidebar(); 457 | }); 458 | 459 | ipc.answerMain('set-theme', setTheme); 460 | 461 | ipc.answerMain('set-private-mode', setPrivateMode); 462 | 463 | ipc.answerMain('update-vibrancy', () => { 464 | updateVibrancy(); 465 | }); 466 | 467 | ipc.answerMain('render-overlay-icon', (messageCount: number): {data: string; text: string} => ({ 468 | data: renderOverlayIcon(messageCount).toDataURL(), 469 | text: String(messageCount), 470 | })); 471 | 472 | ipc.answerMain('render-native-emoji', (emoji: string): string => { 473 | const canvas = document.createElement('canvas'); 474 | const context = canvas.getContext('2d')!; 475 | const systemFont = is.linux ? 'emoji, system-ui' : 'system-ui'; 476 | canvas.width = 256; 477 | canvas.height = 256; 478 | context.textAlign = 'center'; 479 | context.textBaseline = 'middle'; 480 | if (is.macos) { 481 | context.font = `256px ${systemFont}`; 482 | context.fillText(emoji, 128, 154); 483 | } else { 484 | context.textBaseline = 'bottom'; 485 | context.font = `225px ${systemFont}`; 486 | context.fillText(emoji, 128, 256); 487 | } 488 | 489 | const dataUrl = canvas.toDataURL(); 490 | return dataUrl; 491 | }); 492 | 493 | ipc.answerMain('zoom-reset', async () => { 494 | await setZoom(1); 495 | }); 496 | 497 | ipc.answerMain('zoom-in', async () => { 498 | let zoomFactor = await ipc.callMain('get-config-zoomFactor'); 499 | zoomFactor += 0.1; 500 | 501 | if (zoomFactor < 1.6) { 502 | await setZoom(zoomFactor); 503 | } 504 | }); 505 | 506 | ipc.answerMain('zoom-out', async () => { 507 | let zoomFactor = await ipc.callMain('get-config-zoomFactor'); 508 | zoomFactor -= 0.1; 509 | 510 | if (zoomFactor >= 0.8) { 511 | await setZoom(zoomFactor); 512 | } 513 | }); 514 | 515 | ipc.answerMain('jump-to-conversation', async (key: number) => { 516 | await jumpToConversation(key); 517 | }); 518 | 519 | async function nextConversation(): Promise { 520 | const index = selectedConversationIndex(1); 521 | 522 | if (index !== -1) { 523 | await selectConversation(index); 524 | } 525 | } 526 | 527 | async function previousConversation(): Promise { 528 | const index = selectedConversationIndex(-1); 529 | 530 | if (index !== -1) { 531 | await selectConversation(index); 532 | } 533 | } 534 | 535 | async function jumpToConversation(key: number): Promise { 536 | const index = key - 1; 537 | await selectConversation(index); 538 | } 539 | 540 | // Focus on the conversation with the given index 541 | async function selectConversation(index: number): Promise { 542 | const list = await elementReady(selectors.conversationList, {stopOnDomReady: false}); 543 | 544 | if (!list) { 545 | console.error('Could not find conversations list', selectors.conversationList); 546 | return; 547 | } 548 | 549 | const conversation = list.children[index]; 550 | 551 | if (!conversation) { 552 | console.error('Could not find conversation', index); 553 | return; 554 | } 555 | 556 | conversation.querySelector('[role=link]')!.click(); 557 | } 558 | 559 | function selectedConversationIndex(offset = 0): number { 560 | const selected = document.querySelector(selectors.selectedConversation); 561 | 562 | if (!selected) { 563 | return -1; 564 | } 565 | 566 | const newSelected = selected.closest(`${selectors.conversationList} > div`)!; 567 | 568 | const list = [...newSelected.parentNode!.children]; 569 | const index = list.indexOf(newSelected) + offset; 570 | 571 | return ((index % list.length) + list.length) % list.length; 572 | } 573 | 574 | async function setZoom(zoomFactor: number): Promise { 575 | const node = document.querySelector('#zoomFactor')!; 576 | node.textContent = `${selectors.conversationSelector} {zoom: ${zoomFactor} !important}`; 577 | await ipc.callMain('set-config-zoomFactor', zoomFactor); 578 | } 579 | 580 | async function withConversationMenu(callback: () => void): Promise { 581 | // eslint-disable-next-line @typescript-eslint/ban-types 582 | let menuButton: HTMLElement | null = null; 583 | const conversation = document.querySelector(selectors.selectedConversation)!.closest(`${selectors.conversationList} > div`); 584 | 585 | menuButton = conversation?.querySelector('[aria-label=Menu][role=button]') ?? null; 586 | 587 | if (menuButton) { 588 | await withMenu(menuButton, callback); 589 | } 590 | } 591 | 592 | async function openMuteModal(): Promise { 593 | await withConversationMenu(() => { 594 | selectMenuItem(2); 595 | }); 596 | } 597 | 598 | /* 599 | These functions assume: 600 | - There is a selected conversation. 601 | - That the conversation already has its conversation menu open. 602 | 603 | In other words, you should only use this function within a callback that is provided to `withConversationMenu()`, because `withConversationMenu()` makes sure to have the conversation menu open before executing the callback and closes the conversation menu afterwards. 604 | */ 605 | function isSelectedConversationGroup(): boolean { 606 | // Individual conversations include an entry for "View Profile", which is type `a` 607 | return !document.querySelector(`${selectors.conversationMenuSelectorNewDesign} a[role=menuitem]`); 608 | } 609 | 610 | function isSelectedConversationMetaAI(): boolean { 611 | // Meta AI menu only has 1 separator of type `hr` 612 | return !document.querySelector(`${selectors.conversationMenuSelectorNewDesign} hr:nth-of-type(2)`); 613 | } 614 | 615 | async function archiveSelectedConversation(): Promise { 616 | await withConversationMenu(() => { 617 | const [isGroup, isNotGroup, isMetaAI] = [-4, -3, -2]; 618 | 619 | let archiveMenuIndex; 620 | if (isSelectedConversationMetaAI()) { 621 | archiveMenuIndex = isMetaAI; 622 | } else if (isSelectedConversationGroup()) { 623 | archiveMenuIndex = isGroup; 624 | } else { 625 | archiveMenuIndex = isNotGroup; 626 | } 627 | 628 | selectMenuItem(archiveMenuIndex); 629 | }); 630 | } 631 | 632 | async function deleteSelectedConversation(): Promise { 633 | await withConversationMenu(() => { 634 | const [isGroup, isNotGroup, isMetaAI] = [-3, -2, -1]; 635 | 636 | let deleteMenuIndex; 637 | if (isSelectedConversationMetaAI()) { 638 | deleteMenuIndex = isMetaAI; 639 | } else if (isSelectedConversationGroup()) { 640 | deleteMenuIndex = isGroup; 641 | } else { 642 | deleteMenuIndex = isNotGroup; 643 | } 644 | 645 | selectMenuItem(deleteMenuIndex); 646 | }); 647 | } 648 | 649 | async function openPreferences(): Promise { 650 | await withSettingsMenu(() => { 651 | selectMenuItem(1); 652 | }); 653 | 654 | await elementReady(selectors.preferencesSelector, {stopOnDomReady: false}); 655 | } 656 | 657 | function isPreferencesOpen(): boolean { 658 | return Boolean(document.querySelector(selectors.preferencesSelector)); 659 | } 660 | 661 | async function closePreferences(): Promise { 662 | // Wait for the preferences window to be closed, then remove the class from the document 663 | const preferencesOverlayObserver = new MutationObserver(records => { 664 | const removedRecords = records.filter(({removedNodes}) => removedNodes.length > 0 && (removedNodes[0] as HTMLElement).tagName === 'DIV'); 665 | 666 | // In case there is a div removed, hide utility class and stop observing 667 | if (removedRecords.length > 0) { 668 | document.documentElement.classList.remove('hide-preferences-window'); 669 | preferencesOverlayObserver.disconnect(); 670 | } 671 | }); 672 | 673 | const preferencesOverlay = document.querySelector(selectors.preferencesSelector)!; 674 | 675 | // Get the parent of preferences, that's not getting deleted 676 | const preferencesParent = preferencesOverlay.closest('div:not([class])')!; 677 | 678 | preferencesOverlayObserver.observe(preferencesParent, {childList: true}); 679 | 680 | const closeButton = preferencesOverlay.querySelector(selectors.closePreferencesButton)!; 681 | (closeButton as HTMLElement)?.click(); 682 | } 683 | 684 | function insertionListener(event: AnimationEvent): void { 685 | if (event.animationName === 'nodeInserted' && event.target) { 686 | event.target.dispatchEvent(new Event('mouseover', {bubbles: true})); 687 | } 688 | } 689 | 690 | async function observeAutoscroll(): Promise { 691 | const mainElement = await elementReady('._4sp8', {stopOnDomReady: false}); 692 | if (!mainElement) { 693 | return; 694 | } 695 | 696 | const scrollToBottom = (): void => { 697 | // eslint-disable-next-line @typescript-eslint/ban-types 698 | const scrollableElement: HTMLElement | null = document.querySelector('[role=presentation] .scrollable'); 699 | if (scrollableElement) { 700 | scrollableElement.scroll({ 701 | top: Number.MAX_SAFE_INTEGER, 702 | behavior: 'smooth', 703 | }); 704 | } 705 | }; 706 | 707 | const hookMessageObserver = async (): Promise => { 708 | const chatElement = await elementReady( 709 | '[role=presentation] .scrollable [role = region] > div[id ^= "js_"]', {stopOnDomReady: false}, 710 | ); 711 | 712 | if (chatElement) { 713 | // Scroll to the bottom when opening different conversation 714 | scrollToBottom(); 715 | 716 | const messageObserver = new MutationObserver((record: MutationRecord[]) => { 717 | const newMessages: MutationRecord[] = record.filter(record => 718 | // The mutation is an addition 719 | record.addedNodes.length > 0 720 | // ... of a div (skip the "seen" status change) 721 | && (record.addedNodes[0] as HTMLElement).tagName === 'DIV' 722 | // ... on the last child (skip previous messages added when scrolling up) 723 | && chatElement.lastChild!.contains(record.target), 724 | ); 725 | 726 | if (newMessages.length > 0) { 727 | // Scroll to the bottom when there are new messages 728 | scrollToBottom(); 729 | } 730 | }); 731 | 732 | messageObserver.observe(chatElement, {childList: true, subtree: true}); 733 | } 734 | }; 735 | 736 | hookMessageObserver(); 737 | 738 | // Hook it again if conversation changes 739 | const conversationObserver = new MutationObserver(hookMessageObserver); 740 | conversationObserver.observe(mainElement, {childList: true}); 741 | } 742 | 743 | async function observeThemeBugs(): Promise { 744 | const rootObserver = new MutationObserver((record: MutationRecord[]) => { 745 | const newNodes: MutationRecord[] = record 746 | .filter(record => record.addedNodes.length > 0 || record.removedNodes.length > 0); 747 | 748 | if (newNodes) { 749 | removeThemeClasses(Boolean(nativeTheme.shouldUseDarkColors)); 750 | } 751 | }); 752 | 753 | rootObserver.observe(document.documentElement, {childList: true, subtree: true}); 754 | } 755 | 756 | // Listen for emoji element dom insertion 757 | document.addEventListener('animationstart', insertionListener, false); 758 | 759 | // Inject a global style node to maintain custom appearance after conversation change or startup 760 | document.addEventListener('DOMContentLoaded', async () => { 761 | const style = document.createElement('style'); 762 | style.id = 'zoomFactor'; 763 | document.body.append(style); 764 | 765 | // Set the zoom factor if it was set before quitting 766 | const zoomFactor = await ipc.callMain('get-config-zoomFactor'); 767 | setZoom(zoomFactor); 768 | 769 | // Enable OS specific styles 770 | document.documentElement.classList.add(`os-${process.platform}`); 771 | 772 | // Restore sidebar view state to what is was set before quitting 773 | updateSidebar(); 774 | 775 | // Activate Dark Mode if it was set before quitting 776 | setTheme(); 777 | // Observe for dark mode changes 778 | observeTheme(); 779 | 780 | // Activate Private Mode if it was set before quitting 781 | setPrivateMode(); 782 | 783 | // Configure do not disturb 784 | if (is.macos) { 785 | await updateDoNotDisturb(); 786 | } 787 | 788 | // Prevent flash of white on startup when in dark mode 789 | // TODO: find a CSS-only solution 790 | if (!is.macos && nativeTheme.shouldUseDarkColors) { 791 | document.documentElement.style.backgroundColor = '#1e1e1e'; 792 | } 793 | 794 | // Disable autoplay if set in settings 795 | toggleVideoAutoplay(); 796 | 797 | // Hook auto-scroll observer 798 | observeAutoscroll(); 799 | 800 | // Hook broken dark mode observer 801 | observeThemeBugs(); 802 | }); 803 | 804 | // Handle title bar double-click. 805 | window.addEventListener('dblclick', (event: Event) => { 806 | const target = event.target as HTMLElement; 807 | const titleBar = target.closest('._36ic._5l-3,._5742,._6-xk,._673w'); 808 | 809 | if (!titleBar) { 810 | return; 811 | } 812 | 813 | ipc.callMain('titlebar-doubleclick'); 814 | }, { 815 | passive: true, 816 | }); 817 | 818 | window.addEventListener('load', async () => { 819 | if (location.pathname.startsWith('/login')) { 820 | const keepMeSignedInCheckbox = document.querySelector('[id^="u_0_0"]')!; 821 | const keepMeSignedInConfig = await ipc.callMain('get-config-keepMeSignedIn'); 822 | keepMeSignedInCheckbox.checked = keepMeSignedInConfig; 823 | keepMeSignedInCheckbox.addEventListener('change', async () => { 824 | const keepMeSignedIn = await ipc.callMain('get-config-keepMeSignedIn'); 825 | await ipc.callMain('set-config-keepMeSignedIn', keepMeSignedIn); 826 | }); 827 | } 828 | }); 829 | 830 | // Toggles styles for inactive window 831 | window.addEventListener('blur', () => { 832 | document.documentElement.classList.add('is-window-inactive'); 833 | }); 834 | window.addEventListener('focus', () => { 835 | document.documentElement.classList.remove('is-window-inactive'); 836 | }); 837 | 838 | // It's not possible to add multiple accelerators 839 | // so this needs to be done the old-school way 840 | document.addEventListener('keydown', async event => { 841 | // The `!event.altKey` part is a workaround for https://github.com/electron/electron/issues/13895 842 | const combineKey = is.macos ? event.metaKey : event.ctrlKey && !event.altKey; 843 | 844 | if (!combineKey) { 845 | return; 846 | } 847 | 848 | if (event.key === ']') { 849 | await nextConversation(); 850 | } 851 | 852 | if (event.key === '[') { 853 | await previousConversation(); 854 | } 855 | 856 | const number = Number.parseInt(event.code.slice(-1), 10); 857 | 858 | if (number >= 1 && number <= 9) { 859 | await jumpToConversation(number); 860 | } 861 | }); 862 | 863 | // Pass events sent via `window.postMessage` on to the main process 864 | window.addEventListener('message', async ({data: {type, data}}) => { 865 | if (type === 'notification') { 866 | showNotification(data as NotificationEvent); 867 | } 868 | 869 | if (type === 'notification-reply') { 870 | await sendReply(data.reply as string); 871 | 872 | if (data.previousConversation) { 873 | await selectConversation(data.previousConversation as number); 874 | } 875 | } 876 | }); 877 | 878 | function showNotification({id, title, body, icon, silent}: NotificationEvent): void { 879 | const image = new Image(); 880 | image.crossOrigin = 'anonymous'; 881 | image.src = icon; 882 | 883 | image.addEventListener('load', () => { 884 | const canvas = document.createElement('canvas'); 885 | const context = canvas.getContext('2d')!; 886 | 887 | canvas.width = image.width; 888 | canvas.height = image.height; 889 | 890 | context.drawImage(image, 0, 0, image.width, image.height); 891 | 892 | ipc.callMain('notification', { 893 | id, 894 | title, 895 | body, 896 | icon: canvas.toDataURL(), 897 | silent, 898 | }); 899 | }); 900 | } 901 | 902 | async function sendReply(message: string): Promise { 903 | const inputField = document.querySelector('[contenteditable="true"]'); 904 | if (!inputField) { 905 | return; 906 | } 907 | 908 | const previousMessage = inputField.textContent; 909 | 910 | // Send message 911 | inputField.focus(); 912 | insertMessageText(message, inputField); 913 | 914 | const sendButton = await elementReady('._30yy._38lh', {stopOnDomReady: false}); 915 | if (!sendButton) { 916 | console.error('Could not find send button'); 917 | return; 918 | } 919 | 920 | sendButton.click(); 921 | 922 | // Restore (possible) previous message 923 | if (previousMessage) { 924 | insertMessageText(previousMessage, inputField); 925 | } 926 | } 927 | 928 | function insertMessageText(text: string, inputField: HTMLElement): void { 929 | // Workaround: insert placeholder value to get execCommand working 930 | if (!inputField.textContent) { 931 | const event = new InputEvent('textInput', { 932 | bubbles: true, 933 | cancelable: true, 934 | data: '_', 935 | view: window, 936 | }); 937 | inputField.dispatchEvent(event); 938 | } 939 | 940 | document.execCommand('selectAll', false, undefined); 941 | document.execCommand('insertText', false, text); 942 | } 943 | 944 | ipc.answerMain('notification-callback', (data: unknown) => { 945 | window.postMessage({type: 'notification-callback', data}, '*'); 946 | }); 947 | 948 | ipc.answerMain('notification-reply-callback', async (data: any) => { 949 | const previousConversation = selectedConversationIndex(); 950 | data.previousConversation = previousConversation; 951 | window.postMessage({type: 'notification-reply-callback', data}, '*'); 952 | }); 953 | -------------------------------------------------------------------------------- /source/browser/conversation-list.ts: -------------------------------------------------------------------------------- 1 | import {ipcRenderer as ipc} from 'electron-better-ipc'; 2 | import elementReady from 'element-ready'; 3 | import {isNull} from 'lodash'; 4 | import selectors from './selectors'; 5 | 6 | const icon = { 7 | read: 'data-caprine-icon', 8 | unread: 'data-caprine-icon-unread', 9 | }; 10 | 11 | const padding = { 12 | top: 3, 13 | right: 0, 14 | bottom: 3, 15 | left: 0, 16 | }; 17 | 18 | function drawIcon(size: number, img?: HTMLImageElement): HTMLCanvasElement { 19 | const canvas = document.createElement('canvas'); 20 | 21 | if (img) { 22 | canvas.width = size + padding.left + padding.right; 23 | canvas.height = size + padding.top + padding.bottom; 24 | 25 | const context = canvas.getContext('2d')!; 26 | context.beginPath(); 27 | context.arc((size / 2) + padding.left, (size / 2) + padding.top, (size / 2), 0, Math.PI * 2, true); 28 | context.closePath(); 29 | context.clip(); 30 | 31 | context.drawImage(img, padding.left, padding.top, size, size); 32 | } else { 33 | canvas.width = 0; 34 | canvas.height = 0; 35 | } 36 | 37 | return canvas; 38 | } 39 | 40 | // Return canvas with rounded image 41 | async function urlToCanvas(url: string, size: number): Promise { 42 | return new Promise(resolve => { 43 | const img = new Image(); 44 | 45 | img.setAttribute('crossorigin', 'anonymous'); 46 | 47 | img.addEventListener('load', () => { 48 | resolve(drawIcon(size, img)); 49 | }); 50 | 51 | img.addEventListener('error', () => { 52 | console.error('Image not found', url); 53 | resolve(drawIcon(size)); 54 | }); 55 | 56 | img.src = url; 57 | }); 58 | } 59 | 60 | async function createIcons(element: HTMLElement, url: string): Promise { 61 | const canvas = await urlToCanvas(url, 50); 62 | 63 | element.setAttribute(icon.read, canvas.toDataURL()); 64 | 65 | const markerSize = 8; 66 | const context = canvas.getContext('2d')!; 67 | 68 | context.fillStyle = '#f42020'; 69 | context.beginPath(); 70 | context.ellipse(canvas.width - markerSize, markerSize, markerSize, markerSize, 0, 0, 2 * Math.PI); 71 | context.closePath(); 72 | context.fill(); 73 | 74 | element.setAttribute(icon.unread, canvas.toDataURL()); 75 | } 76 | 77 | async function discoverIcons(element: HTMLElement): Promise { 78 | if (element) { 79 | return createIcons(element, element.getAttribute('src')!); 80 | } 81 | 82 | console.warn('Could not discover profile picture. Falling back to default image.'); 83 | 84 | // Fall back to messenger favicon 85 | const messengerIcon = document.querySelector('link[rel~="icon"]'); 86 | 87 | if (messengerIcon) { 88 | return createIcons(element, messengerIcon.getAttribute('href')!); 89 | } 90 | 91 | // Fall back to facebook favicon 92 | return createIcons(element, 'https://facebook.com/favicon.ico'); 93 | } 94 | 95 | async function getIcon(element: HTMLElement, unread: boolean): Promise { 96 | if (element === null) { 97 | return icon.read; 98 | } 99 | 100 | if (!element.getAttribute(icon.read)) { 101 | await discoverIcons(element); 102 | } 103 | 104 | return element.getAttribute(unread ? icon.unread : icon.read)!; 105 | } 106 | 107 | async function getLabel(element: HTMLElement): Promise { 108 | if (isNull(element)) { 109 | return ''; 110 | } 111 | 112 | const emojis: HTMLElement[] = []; 113 | if (element !== null) { 114 | for (const elementCurrent of element.children) { 115 | emojis.push(elementCurrent as HTMLElement); 116 | } 117 | } 118 | 119 | for (const emoji of emojis) { 120 | emoji.outerHTML = emoji.querySelector('img')?.getAttribute('alt') ?? ''; 121 | } 122 | 123 | return element.textContent ?? ''; 124 | } 125 | 126 | async function createConversationNewDesign(element: HTMLElement): Promise { 127 | const conversation: Partial = {}; 128 | // TODO: Exclude muted conversations 129 | /* 130 | const muted = Boolean(element.querySelector(selectors.muteIconNewDesign)); 131 | */ 132 | 133 | conversation.selected = Boolean(element.querySelector('[role=row] [role=link] > div:only-child')); 134 | conversation.unread = Boolean(element.querySelector('[aria-label="Mark as Read"]')); 135 | 136 | const unparsedLabel = element.querySelector('.a8c37x1j.ni8dbmo4.stjgntxs.l9j0dhe7 > span > span')!; 137 | conversation.label = await getLabel(unparsedLabel); 138 | 139 | const iconElement = element.querySelector('img')!; 140 | conversation.icon = await getIcon(iconElement, conversation.unread); 141 | 142 | return conversation as Conversation; 143 | } 144 | 145 | async function createConversationList(): Promise { 146 | const conversationListSelector = selectors.conversationList; 147 | 148 | const list = await elementReady(conversationListSelector, { 149 | stopOnDomReady: false, 150 | }); 151 | 152 | if (!list) { 153 | console.error('Could not find conversation list', conversationListSelector); 154 | return []; 155 | } 156 | 157 | const elements: HTMLElement[] = [...list.children] as HTMLElement[]; 158 | 159 | // Remove last element from childer list 160 | elements.splice(-1, 1); 161 | 162 | const conversations: Conversation[] = await Promise.all(elements.map(async element => createConversationNewDesign(element))); 163 | 164 | return conversations; 165 | } 166 | 167 | export async function sendConversationList(): Promise { 168 | const conversationsToRender: Conversation[] = await createConversationList(); 169 | ipc.callMain('conversations', conversationsToRender); 170 | } 171 | 172 | function generateStringFromNode(element: Element): string | undefined { 173 | const cloneElement = element.cloneNode(true) as Element; 174 | let emojiString; 175 | 176 | const images = cloneElement.querySelectorAll('img'); 177 | for (const image of images) { 178 | emojiString = image.alt; 179 | // Replace facebook's thumbs up with emoji 180 | if (emojiString === '(Y)' || emojiString === '(y)') { 181 | emojiString = '👍'; 182 | } 183 | 184 | image.parentElement?.replaceWith(document.createTextNode(emojiString)); 185 | } 186 | 187 | return cloneElement.textContent ?? undefined; 188 | } 189 | 190 | function countUnread(mutationsList: MutationRecord[]): void { 191 | const alreadyChecked: string[] = []; 192 | 193 | const unreadMutations = mutationsList.filter(mutation => 194 | // When a conversations "becomes unread". 195 | ( 196 | mutation.type === 'childList' 197 | && mutation.addedNodes.length > 0 198 | && ((mutation.addedNodes[0] as Element).className === selectors.conversationSidebarUnreadDot) 199 | ) 200 | // When text is received 201 | || ( 202 | mutation.type === 'characterData' 203 | // Make sure the text corresponds to a conversation 204 | && mutation.target.parentElement?.parentElement?.parentElement?.className === selectors.conversationSidebarTextParent 205 | ) 206 | // When an emoji is received, node(s) are added 207 | || ( 208 | mutation.type === 'childList' 209 | // Make sure the mutation corresponds to a conversation 210 | && mutation.target.parentElement?.parentElement?.className === selectors.conversationSidebarTextParent 211 | ) 212 | // Emoji change 213 | || ( 214 | mutation.type === 'attributes' 215 | && mutation.target.parentElement?.parentElement?.parentElement?.parentElement?.className === selectors.conversationSidebarTextParent 216 | )); 217 | 218 | // Check latest mutation first 219 | for (const mutation of unreadMutations.reverse()) { 220 | const current = (mutation.target.parentElement as Element).closest(selectors.conversationSidebarSelector)!; 221 | 222 | const href = current.closest('[role="link"]')?.getAttribute('href'); 223 | 224 | if (!href) { 225 | continue; 226 | } 227 | 228 | // It is possible to have multiple mutations for the same conversation, but we only want one notification. 229 | // So if the current conversation has already been checked, continue. 230 | // Additionally if the conversation is not unread, then also continue. 231 | if (alreadyChecked.includes(href) || !current.querySelector('.' + selectors.conversationSidebarUnreadDot.replaceAll(/ /, '.'))) { 232 | continue; 233 | } 234 | 235 | alreadyChecked.push(href); 236 | 237 | // Get the image data URI from the parent of the author/text 238 | const imgUrl = current.querySelector('img')?.dataset.caprineIcon; 239 | const textOptions = current.querySelectorAll(selectors.conversationSidebarTextSelector); 240 | // Get the author and text of the new message 241 | const titleTextNode = textOptions[0]; 242 | const bodyTextNode = textOptions[1]; 243 | 244 | const titleText = generateStringFromNode(titleTextNode); 245 | const bodyText = generateStringFromNode(bodyTextNode); 246 | 247 | if (!bodyText || !titleText || !imgUrl) { 248 | continue; 249 | } 250 | 251 | // Send a notification 252 | ipc.callMain('notification', { 253 | id: 0, 254 | title: titleText, 255 | body: bodyText, 256 | icon: imgUrl, 257 | silent: false, 258 | }); 259 | } 260 | } 261 | 262 | async function updateTrayIcon(): Promise { 263 | let messageCount = 0; 264 | 265 | await elementReady(selectors.chatsIcon, {stopOnDomReady: false}); 266 | 267 | // Count unread messages in Chats, Marketplace, etc. 268 | for (const element of document.querySelectorAll(selectors.chatsIcon)) { 269 | // Extract messageNumber from ariaLabel 270 | const messageNumber = element?.ariaLabel?.match(/\d+/g); 271 | 272 | if (messageNumber) { 273 | messageCount += Number.parseInt(messageNumber[0], 10); 274 | } 275 | } 276 | 277 | ipc.callMain('update-tray-icon', messageCount); 278 | } 279 | 280 | window.addEventListener('load', async () => { 281 | const sidebar = await elementReady('[role=navigation]:has([role=grid])', {stopOnDomReady: false}); 282 | const leftSidebar = await elementReady(`${selectors.leftSidebar}:has(${selectors.chatsIcon})`, {stopOnDomReady: false}); 283 | 284 | if (sidebar) { 285 | const conversationListObserver = new MutationObserver(async () => sendConversationList()); 286 | const conversationCountObserver = new MutationObserver(countUnread); 287 | 288 | conversationListObserver.observe(sidebar, { 289 | subtree: true, 290 | childList: true, 291 | attributes: true, 292 | attributeFilter: ['class'], 293 | }); 294 | 295 | conversationCountObserver.observe(sidebar, { 296 | characterData: true, 297 | subtree: true, 298 | childList: true, 299 | attributes: true, 300 | attributeFilter: ['src', 'alt'], 301 | }); 302 | } 303 | 304 | if (leftSidebar) { 305 | const chatsIconObserver = new MutationObserver(async () => updateTrayIcon()); 306 | 307 | chatsIconObserver.observe(leftSidebar, { 308 | subtree: true, 309 | childList: true, 310 | attributes: true, 311 | attributeFilter: ['aria-label'], 312 | }); 313 | } 314 | }); 315 | -------------------------------------------------------------------------------- /source/browser/selectors.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | leftSidebar: '[role="navigation"][class="x9f619 x1n2onr6 x1ja2u2z x78zum5 xdt5ytf x2lah0s x193iq5w xeuugli"] > div > div', // ! Tray icon dependency 3 | chatsIcon: '[class="x9f619 x1n2onr6 x1ja2u2z x78zum5 xdt5ytf x2lah0s x193iq5w xdj266r"] a', // ! Tray icon dependency 4 | conversationList: '[role=navigation] [role=grid] [class="x1n2onr6"]', 5 | conversationSelector: '[role=main] [role=grid]', 6 | conversationSidebarUnreadDot: 'x1i10hfl x1qjc9v5 xjbqb8w xjqpnuy xa49m3k xqeqjp1 x2hbi6w x13fuv20 xu3j5b3 x1q0q8m5 x26u7qi x972fbf xcfux6l x1qhh985 xm0m39n x9f619 x1ypdohk xdl72j9 x2lah0s xe8uvvx xdj266r x11i5rnm xat24cr x1mh8g0r x2lwn1j xeuugli xexx8yu x4uap5 x18d9i69 xkhd6sd x1n2onr6 x16tdsg8 x1hl2dhg xggy1nq x1ja2u2z x1t137rt x1o1ewxj x3x9cwd x1e5q0jg x13rtm0m x1q0g3np x87ps6o x1lku1pv x78zum5 x1a2a7pz', 7 | conversationSidebarTextParent: 'html-span xdj266r x11i5rnm xat24cr x1mh8g0r xexx8yu x18d9i69 xkhd6sd x1hl2dhg x16tdsg8 x1vvkbs x6s0dn4 x9f619 x78zum5 x193iq5w xeuugli xg83lxy', // Parent element of the conversation text element (needed for notifications) 8 | conversationSidebarTextSelector: '[class="x1lliihq x193iq5w x6ikm8r x10wlt62 xlyipyv xuxw1ft"]', // Generic selector for the text contents of all conversations 9 | conversationSidebarSelector: '[class="x9f619 x1n2onr6 x1ja2u2z x78zum5 x2lah0s x1qughib x6s0dn4 xozqiw3 x1q0g3np"]', // Selector for the top level element of a single conversation (children contain text content of the conversation and conversation image) 10 | notificationCheckbox: '._374b:nth-of-type(4) ._4ng2 input', 11 | rightSidebarMenu: '.x6s0dn4.x3nfvp2.x1fgtraw.xl56j7k.x1n2onr6.xgd8bvy', 12 | rightSidebarButtons: '.x9f619.x1ja2u2z.x78zum5.x2lah0s.x1n2onr6.xl56j7k.x1qjc9v5.xozqiw3.x1q0g3np.xn6708d.x1ye3gou.x1cnzs8.xdj266r.x11i5rnm.xat24cr.x1mh8g0r > div [role=button]', 13 | muteIconNewDesign: 'path[d="M29.676 7.746c.353-.352.44-.92.15-1.324a1 1 0 00-1.524-.129L6.293 28.29a1 1 0 00.129 1.523c.404.29.972.204 1.324-.148l3.082-3.08A2.002 2.002 0 0112.242 26h15.244c.848 0 1.57-.695 1.527-1.541-.084-1.643-1.87-1.145-2.2-3.515l-1.073-8.157-.002-.01a1.976 1.976 0 01.562-1.656l3.376-3.375zm-9.165 20.252H15.51c-.313 0-.565.275-.506.575.274 1.38 1.516 2.422 3.007 2.422 1.49 0 2.731-1.042 3.005-2.422.06-.3-.193-.575-.505-.575zm-10.064-6.719L22.713 9.02a.997.997 0 00-.124-1.51 7.792 7.792 0 00-12.308 5.279l-1.04 7.897c-.089.672.726 1.074 1.206.594z"]', 14 | // ! Very fragile selector (most likely cause of hidden dialog issue) 15 | closePreferencesButton: 'div[role=dialog] > div > div > div:nth-child(2) > [role=button]', 16 | userMenu: '.qi72231t.o9w3sbdw.nu7423ey.tav9wjvu.flwp5yud.tghlliq5.gkg15gwv.s9ok87oh.s9ljgwtm.lxqftegz.bf1zulr9.frfouenu.bonavkto.djs4p424.r7bn319e.bdao358l.fsf7x5fv.tgm57n0e.jez8cy9q.s5oniofx.m8h3af8h.l7ghb35v.kjdc1dyq.kmwttqpk.dnr7xe2t.aeinzg81.srn514ro.oxkhqvkx.rl78xhln.nch0832m.om3e55n1.cr00lzj9.rn8ck1ys.s3jn8y49.g4tp4svg.o9erhkwx.dzqi5evh.hupbnkgi.hvb2xoa8.fxk3tzhb.jl2a5g8c.f14ij5to.l3ldwz01.icdlwmnq > .aglvbi8b.om3e55n1.i8zpp7h3.g4tp4svg', 17 | userMenuNewSidebar: '[role=navigation] > div > div:nth-child(2) > div > div > div:nth-child(1) [role=button]', 18 | viewsMenu: '.x9f619.x1n2onr6.x1ja2u2z.x78zum5.xdt5ytf.x2lah0s.x193iq5w.xdj266r', 19 | selectedConversation: '[role=navigation] [role=grid] [role=row] [role=gridcell] [role=link][aria-current=page]', 20 | // ! Very fragile selector (most likely cause of hidden dialog issue) 21 | preferencesSelector: '.x1n2onr6.x1ja2u2z.x1afcbsf.x78zum5.xdt5ytf.x1a2a7pz.x6ikm8r.x10wlt62.x71s49j.x1jx94hy.x1g2kw80.xxadwq3.x16n5opg.x3hh19s.xl7ujzl.x1kl8bxo.xhkep3z.xb3b7hn.xwhkkir.x1n7qst7.x17omtbh:has(.x1l90r2v.x1swvt13.x1pi30zi)', 22 | // TODO: Fix this selector for new design 23 | messengerSoundsSelector: '._374d ._6bkz', 24 | conversationMenuSelectorNewDesign: '[role=menu]', 25 | }; 26 | -------------------------------------------------------------------------------- /source/config.ts: -------------------------------------------------------------------------------- 1 | import Store from 'electron-store'; 2 | import {is} from 'electron-util'; 3 | import {EmojiStyle} from './emoji'; 4 | 5 | export type StoreType = { 6 | theme: 'system' | 'light' | 'dark'; 7 | privateMode: boolean; 8 | showPrivateModePrompt: boolean; 9 | vibrancy: 'none' | 'sidebar' | 'full'; 10 | zoomFactor: number; 11 | lastWindowState: { 12 | x: number; 13 | y: number; 14 | width: number; 15 | height: number; 16 | isMaximized: boolean; 17 | }; 18 | menuBarMode: boolean; 19 | showDockIcon: boolean; 20 | showTrayIcon: boolean; 21 | alwaysOnTop: boolean; 22 | showAlwaysOnTopPrompt: boolean; 23 | bounceDockOnMessage: boolean; 24 | showUnreadBadge: boolean; 25 | showMessageButtons: boolean; 26 | launchMinimized: boolean; 27 | flashWindowOnMessage: boolean; 28 | notificationMessagePreview: boolean; 29 | block: { 30 | chatSeen: boolean; 31 | typingIndicator: boolean; 32 | deliveryReceipt: boolean; 33 | }; 34 | emojiStyle: EmojiStyle; 35 | useWorkChat: boolean; 36 | sidebar: 'default' | 'hidden' | 'narrow' | 'wide'; 37 | autoHideMenuBar: boolean; 38 | autoUpdate: boolean; 39 | notificationsMuted: boolean; 40 | callRingtoneMuted: boolean; 41 | hardwareAcceleration: boolean; 42 | quitOnWindowClose: boolean; 43 | keepMeSignedIn: boolean; 44 | autoplayVideos: boolean; 45 | isSpellCheckerEnabled: boolean; 46 | spellCheckerLanguages: string[]; 47 | }; 48 | 49 | const schema: Store.Schema = { 50 | theme: { 51 | type: 'string', 52 | enum: ['system', 'light', 'dark'], 53 | default: 'system', 54 | }, 55 | privateMode: { 56 | type: 'boolean', 57 | default: false, 58 | }, 59 | showPrivateModePrompt: { 60 | type: 'boolean', 61 | default: true, 62 | }, 63 | vibrancy: { 64 | type: 'string', 65 | enum: ['none', 'sidebar', 'full'], 66 | // TODO: Change the default to 'sidebar' when the vibrancy issue in Electron is fixed. 67 | // See https://github.com/electron/electron/issues/10420 68 | default: 'none', 69 | }, 70 | zoomFactor: { 71 | type: 'number', 72 | default: 1, 73 | }, 74 | lastWindowState: { 75 | type: 'object', 76 | properties: { 77 | x: { 78 | type: 'number', 79 | }, 80 | y: { 81 | type: 'number', 82 | }, 83 | width: { 84 | type: 'number', 85 | }, 86 | height: { 87 | type: 'number', 88 | }, 89 | isMaximized: { 90 | type: 'boolean', 91 | }, 92 | }, 93 | default: { 94 | x: undefined, 95 | y: undefined, 96 | width: 800, 97 | height: 600, 98 | isMaximized: false, 99 | }, 100 | }, 101 | menuBarMode: { 102 | type: 'boolean', 103 | default: false, 104 | }, 105 | showDockIcon: { 106 | type: 'boolean', 107 | default: true, 108 | }, 109 | showTrayIcon: { 110 | type: 'boolean', 111 | default: true, 112 | }, 113 | alwaysOnTop: { 114 | type: 'boolean', 115 | default: false, 116 | }, 117 | showAlwaysOnTopPrompt: { 118 | type: 'boolean', 119 | default: true, 120 | }, 121 | bounceDockOnMessage: { 122 | type: 'boolean', 123 | default: false, 124 | }, 125 | showUnreadBadge: { 126 | type: 'boolean', 127 | default: true, 128 | }, 129 | showMessageButtons: { 130 | type: 'boolean', 131 | default: true, 132 | }, 133 | launchMinimized: { 134 | type: 'boolean', 135 | default: false, 136 | }, 137 | flashWindowOnMessage: { 138 | type: 'boolean', 139 | default: true, 140 | }, 141 | notificationMessagePreview: { 142 | type: 'boolean', 143 | default: true, 144 | }, 145 | block: { 146 | type: 'object', 147 | properties: { 148 | chatSeen: { 149 | type: 'boolean', 150 | }, 151 | typingIndicator: { 152 | type: 'boolean', 153 | }, 154 | deliveryReceipt: { 155 | type: 'boolean', 156 | }, 157 | }, 158 | default: { 159 | chatSeen: false, 160 | typingIndicator: false, 161 | deliveryReceipt: false, 162 | }, 163 | }, 164 | emojiStyle: { 165 | type: 'string', 166 | enum: ['native', 'facebook-3-0', 'messenger-1-0', 'facebook-2-2'], 167 | default: 'facebook-3-0', 168 | }, 169 | useWorkChat: { 170 | type: 'boolean', 171 | default: false, 172 | }, 173 | sidebar: { 174 | type: 'string', 175 | enum: ['default', 'hidden', 'narrow', 'wide'], 176 | default: 'default', 177 | }, 178 | autoHideMenuBar: { 179 | type: 'boolean', 180 | default: false, 181 | }, 182 | autoUpdate: { 183 | type: 'boolean', 184 | default: true, 185 | }, 186 | notificationsMuted: { 187 | type: 'boolean', 188 | default: false, 189 | }, 190 | callRingtoneMuted: { 191 | type: 'boolean', 192 | default: false, 193 | }, 194 | hardwareAcceleration: { 195 | type: 'boolean', 196 | default: true, 197 | }, 198 | quitOnWindowClose: { 199 | type: 'boolean', 200 | default: false, 201 | }, 202 | keepMeSignedIn: { 203 | type: 'boolean', 204 | default: true, 205 | }, 206 | autoplayVideos: { 207 | type: 'boolean', 208 | default: true, 209 | }, 210 | isSpellCheckerEnabled: { 211 | type: 'boolean', 212 | default: true, 213 | }, 214 | spellCheckerLanguages: { 215 | type: 'array', 216 | items: { 217 | type: 'string', 218 | }, 219 | default: [], 220 | }, 221 | }; 222 | 223 | function updateVibrancySetting(store: Store): void { 224 | const vibrancy = store.get('vibrancy'); 225 | 226 | if (!is.macos || !vibrancy) { 227 | store.set('vibrancy', 'none'); 228 | // @ts-expect-error 229 | } else if (vibrancy === true) { 230 | store.set('vibrancy', 'full'); 231 | // @ts-expect-error 232 | } else if (vibrancy === false) { 233 | store.set('vibrancy', 'sidebar'); 234 | } 235 | } 236 | 237 | function updateSidebarSetting(store: Store): void { 238 | if (store.get('sidebarHidden')) { 239 | store.set('sidebar', 'hidden'); 240 | // @ts-expect-error 241 | store.delete('sidebarHidden'); 242 | } else if (!store.has('sidebar')) { 243 | store.set('sidebar', 'default'); 244 | } 245 | } 246 | 247 | function updateThemeSetting(store: Store): void { 248 | const darkMode = store.get('darkMode'); 249 | const followSystemAppearance = store.get('followSystemAppearance'); 250 | 251 | if (is.macos && followSystemAppearance) { 252 | store.set('theme', 'system'); 253 | } else if (darkMode !== undefined) { 254 | store.set('theme', darkMode ? 'dark' : 'light'); 255 | } else if (!store.has('theme')) { 256 | store.set('theme', 'system'); 257 | } 258 | 259 | if (darkMode !== undefined) { 260 | // @ts-expect-error 261 | store.delete('darkMode'); 262 | } 263 | 264 | if (followSystemAppearance !== undefined) { 265 | // @ts-expect-error 266 | store.delete('followSystemAppearance'); 267 | } 268 | } 269 | 270 | function migrate(store: Store): void { 271 | updateVibrancySetting(store); 272 | updateSidebarSetting(store); 273 | updateThemeSetting(store); 274 | } 275 | 276 | const store = new Store({schema}); 277 | migrate(store); 278 | 279 | export default store; 280 | -------------------------------------------------------------------------------- /source/constants.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | import {fixPathForAsarUnpack} from 'electron-util'; 3 | 4 | export const caprineIconPath = fixPathForAsarUnpack(path.join(__dirname, '..', 'static', 'Icon.png')); 5 | -------------------------------------------------------------------------------- /source/conversation.d.ts: -------------------------------------------------------------------------------- 1 | type Conversation = { 2 | label: string; 3 | selected: boolean; 4 | unread: boolean; 5 | icon: string; 6 | }; 7 | -------------------------------------------------------------------------------- /source/do-not-disturb.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@sindresorhus/do-not-disturb'; 2 | -------------------------------------------------------------------------------- /source/emoji.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | import { 3 | nativeImage, 4 | NativeImage, 5 | MenuItemConstructorOptions, 6 | CallbackResponse, 7 | Menu, 8 | } from 'electron'; 9 | import {is} from 'electron-util'; 10 | import {memoize} from 'lodash'; 11 | import {showRestartDialog, getWindow, sendBackgroundAction} from './util'; 12 | import config from './config'; 13 | 14 | // The list of emojis that aren't supported by older emoji (facebook-2-2, messenger-1-0) 15 | // Based on https://emojipedia.org/facebook/3.0/new/ 16 | const excludedEmoji = new Set([ 17 | 'f0000', // Facebook's thumbs-up icon as shown on the contacts list 18 | '1f3c3_200d_2640', 19 | '1f3c4_200d_2640', 20 | '1f3ca_200d_2640', 21 | '1f3f4_200d_2620', 22 | '1f468_1f3fb_200d_1f9b0', 23 | '1f468_1f3fb_200d_1f9b1', 24 | '1f468_1f3fb_200d_1f9b2', 25 | '1f468_1f3fb_200d_1f9b3', 26 | '1f468_1f3fc_200d_1f9b0', 27 | '1f468_1f3fc_200d_1f9b1', 28 | '1f468_1f3fc_200d_1f9b2', 29 | '1f468_1f3fc_200d_1f9b3', 30 | '1f468_1f3fd_200d_1f9b0', 31 | '1f468_1f3fd_200d_1f9b1', 32 | '1f468_1f3fd_200d_1f9b2', 33 | '1f468_1f3fd_200d_1f9b3', 34 | '1f468_1f3fe_200d_1f9b0', 35 | '1f468_1f3fe_200d_1f9b1', 36 | '1f468_1f3fe_200d_1f9b2', 37 | '1f468_1f3fe_200d_1f9b3', 38 | '1f468_1f3ff_200d_1f9b0', 39 | '1f468_1f3ff_200d_1f9b1', 40 | '1f468_1f3ff_200d_1f9b2', 41 | '1f468_1f3ff_200d_1f9b3', 42 | '1f468_200d_1f9b0', 43 | '1f468_200d_1f9b1', 44 | '1f468_200d_1f9b2', 45 | '1f468_200d_1f9b3', 46 | '1f468_200d_2764_200d_1f468', 47 | '1f468_200d_2764_200d_1f48b_200d_1f468', 48 | '1f469_1f3fb_200d_1f9b0', 49 | '1f469_1f3fb_200d_1f9b1', 50 | '1f469_1f3fb_200d_1f9b2', 51 | '1f469_1f3fb_200d_1f9b3', 52 | '1f469_1f3fc_200d_1f9b0', 53 | '1f469_1f3fc_200d_1f9b1', 54 | '1f469_1f3fc_200d_1f9b2', 55 | '1f469_1f3fc_200d_1f9b3', 56 | '1f469_1f3fd_200d_1f9b0', 57 | '1f469_1f3fd_200d_1f9b1', 58 | '1f469_1f3fd_200d_1f9b2', 59 | '1f469_1f3fd_200d_1f9b3', 60 | '1f469_1f3fe_200d_1f9b0', 61 | '1f469_1f3fe_200d_1f9b1', 62 | '1f469_1f3fe_200d_1f9b2', 63 | '1f469_1f3fe_200d_1f9b3', 64 | '1f469_1f3ff_200d_1f9b0', 65 | '1f469_1f3ff_200d_1f9b1', 66 | '1f469_1f3ff_200d_1f9b2', 67 | '1f469_1f3ff_200d_1f9b3', 68 | '1f469_200d_1f9b0', 69 | '1f469_200d_1f9b1', 70 | '1f469_200d_1f9b2', 71 | '1f469_200d_1f9b3', 72 | '1f469_200d_2764_200d_1f469', 73 | '1f469_200d_2764_200d_1f48b_200d_1f469', 74 | '1f46e_200d_2640', 75 | '1f46f_200d_2640', 76 | '1f471_200d_2640', 77 | '1f473_200d_2640', 78 | '1f477_200d_2640', 79 | '1f481_200d_2640', 80 | '1f482_200d_2640', 81 | '1f486_200d_2640', 82 | '1f487_200d_2640', 83 | '1f645_200d_2640', 84 | '1f646_200d_2640', 85 | '1f647_200d_2640', 86 | '1f64b_200d_2640', 87 | '1f64d_200d_2640', 88 | '1f64e_200d_2640', 89 | '1f6a3_200d_2640', 90 | '1f6b4_200d_2640', 91 | '1f6b5_200d_2640', 92 | '1f6b6_200d_2640', 93 | '1f6f9', 94 | '1f94d', 95 | '1f94e', 96 | '1f94f', 97 | '1f96c', 98 | '1f96d', 99 | '1f96e', 100 | '1f96f', 101 | '1f970', 102 | '1f973', 103 | '1f974', 104 | '1f975', 105 | '1f976', 106 | '1f97a', 107 | '1f97c', 108 | '1f97d', 109 | '1f97e', 110 | '1f97f', 111 | '1f998', 112 | '1f999', 113 | '1f99a', 114 | '1f99b', 115 | '1f99c', 116 | '1f99d', 117 | '1f99e', 118 | '1f99f', 119 | '1f9a0', 120 | '1f9a1', 121 | '1f9a2', 122 | '1f9b0', 123 | '1f9b1', 124 | '1f9b2', 125 | '1f9b3', 126 | '1f9b4', 127 | '1f9b5_1f3fb', 128 | '1f9b5_1f3fc', 129 | '1f9b5_1f3fd', 130 | '1f9b5_1f3fe', 131 | '1f9b5_1f3ff', 132 | '1f9b5', 133 | '1f9b6_1f3fb', 134 | '1f9b6_1f3fc', 135 | '1f9b6_1f3fd', 136 | '1f9b6_1f3fe', 137 | '1f9b6_1f3ff', 138 | '1f9b6', 139 | '1f9b7', 140 | '1f9b8_1f3fb', 141 | '1f9b8_1f3fb_200d_2640', 142 | '1f9b8_1f3fb_200d_2642', 143 | '1f9b8_1f3fc', 144 | '1f9b8_1f3fc_200d_2640', 145 | '1f9b8_1f3fc_200d_2642', 146 | '1f9b8_1f3fd', 147 | '1f9b8_1f3fd_200d_2640', 148 | '1f9b8_1f3fd_200d_2642', 149 | '1f9b8_1f3fe', 150 | '1f9b8_1f3fe_200d_2640', 151 | '1f9b8_1f3fe_200d_2642', 152 | '1f9b8_1f3ff', 153 | '1f9b8_1f3ff_200d_2640', 154 | '1f9b8_1f3ff_200d_2642', 155 | '1f9b8_200d_2640', 156 | '1f9b8_200d_2642', 157 | '1f9b8', 158 | '1f9b9_1f3fb', 159 | '1f9b9_1f3fb_200d_2640', 160 | '1f9b9_1f3fb_200d_2642', 161 | '1f9b9_1f3fc', 162 | '1f9b9_1f3fc_200d_2640', 163 | '1f9b9_1f3fc_200d_2642', 164 | '1f9b9_1f3fd', 165 | '1f9b9_1f3fd_200d_2640', 166 | '1f9b9_1f3fd_200d_2642', 167 | '1f9b9_1f3fe', 168 | '1f9b9_1f3fe_200d_2640', 169 | '1f9b9_1f3fe_200d_2642', 170 | '1f9b9_1f3ff', 171 | '1f9b9_1f3ff_200d_2640', 172 | '1f9b9_1f3ff_200d_2642', 173 | '1f9b9_200d_2640', 174 | '1f9b9_200d_2642', 175 | '1f9b9', 176 | '1f9c1', 177 | '1f9c2', 178 | '1f9e7', 179 | '1f9e8', 180 | '1f9e9', 181 | '1f9ea', 182 | '1f9eb', 183 | '1f9ec', 184 | '1f9ed', 185 | '1f9ee', 186 | '1f9ef', 187 | '1f9f0', 188 | '1f9f1', 189 | '1f9f2', 190 | '1f9f3', 191 | '1f9f4', 192 | '1f9f5', 193 | '1f9f6', 194 | '1f9f7', 195 | '1f9f8', 196 | '1f9f9', 197 | '1f9fa', 198 | '1f9fb', 199 | '1f9fc', 200 | '1f9fd', 201 | '1f9fe', 202 | '1f9ff', 203 | '265f', 204 | '267e', 205 | ]); 206 | 207 | export enum EmojiStyle { 208 | Native = 'native', 209 | Facebook30 = 'facebook-3-0', 210 | Messenger10 = 'messenger-1-0', 211 | Facebook22 = 'facebook-2-2', 212 | } 213 | 214 | enum EmojiStyleCode { 215 | Facebook30 = 't', 216 | Messenger10 = 'z', 217 | Facebook22 = 'f', 218 | } 219 | 220 | function codeForEmojiStyle(style: EmojiStyle): EmojiStyleCode { 221 | switch (style) { 222 | case 'facebook-2-2': { 223 | return EmojiStyleCode.Facebook22; 224 | } 225 | 226 | case 'messenger-1-0': { 227 | return EmojiStyleCode.Messenger10; 228 | } 229 | 230 | default: { 231 | return EmojiStyleCode.Facebook30; 232 | } 233 | } 234 | } 235 | 236 | /** 237 | Renders the given emoji in the renderer process and returns a PNG `data:` URL. 238 | */ 239 | const renderEmoji = memoize(async (emoji: string): Promise => sendBackgroundAction('render-native-emoji', emoji)); 240 | 241 | /** 242 | @param url - A Facebook emoji URL like `https://static.xx.fbcdn.net/images/emoji.php/v9/tae/2/16/1f471_1f3fb_200d_2640.png`. 243 | */ 244 | function urlToEmoji(url: string): string { 245 | const codePoints = url 246 | .split('/') 247 | .pop()! 248 | .replace(/\.png$/, '') 249 | .split('_') 250 | .map(hexCodePoint => Number.parseInt(hexCodePoint, 16)); 251 | 252 | // F0000 (983040 decimal) is Facebook's thumbs-up icon 253 | if (codePoints.length === 1 && codePoints[0] === 983_040) { 254 | return '👍'; 255 | } 256 | 257 | // Emoji is missing Variation Selector-16 (\uFE0F): 258 | // "An invisible codepoint which specifies that the preceding character 259 | // should be displayed with emoji presentation. 260 | // Only required if the preceding character defaults to text presentation." 261 | return String.fromCodePoint(...codePoints) + '\uFE0F'; 262 | } 263 | 264 | const cachedEmojiMenuIcons = new Map(); 265 | 266 | /** 267 | @returns An icon to use for the menu item of this emoji style. 268 | */ 269 | async function getEmojiIcon(style: EmojiStyle): Promise { 270 | const cachedIcon = cachedEmojiMenuIcons.get(style); 271 | 272 | if (cachedIcon) { 273 | return cachedIcon; 274 | } 275 | 276 | if (style === 'native') { 277 | if (!getWindow()) { 278 | return undefined; 279 | } 280 | 281 | const dataUrl = await renderEmoji('🙂'); 282 | const image = nativeImage.createFromDataURL(dataUrl); 283 | const resizedImage = image.resize({width: 16, height: 16}); 284 | 285 | cachedEmojiMenuIcons.set(style, resizedImage); 286 | 287 | return resizedImage; 288 | } 289 | 290 | const image = nativeImage.createFromPath( 291 | path.join(__dirname, '..', 'static', `emoji-${style}.png`), 292 | ); 293 | 294 | cachedEmojiMenuIcons.set(style, image); 295 | 296 | return image; 297 | } 298 | 299 | /** 300 | For example, when 'emojiStyle' setting is set to 'messenger-1-0' it replaces 301 | this URL: https://static.xx.fbcdn.net/images/emoji.php/v9/t27/2/32/1f600.png 302 | with this: https://static.xx.fbcdn.net/images/emoji.php/v9/z27/2/32/1f600.png 303 | (see here) ^ 304 | */ 305 | export async function process(url: string): Promise { 306 | const emojiStyle = config.get('emojiStyle'); 307 | const emojiSetCode = codeForEmojiStyle(emojiStyle); 308 | 309 | // The character code is the filename without the extension. 310 | const characterCodeEnd = url.lastIndexOf('.png'); 311 | const characterCode = url.slice(url.lastIndexOf('/') + 1, characterCodeEnd); 312 | 313 | if (emojiStyle === EmojiStyle.Native) { 314 | const emoji = urlToEmoji(url); 315 | const dataUrl = await renderEmoji(emoji); 316 | return {redirectURL: dataUrl}; 317 | } 318 | 319 | if ( 320 | // Don't replace emoji from Facebook's latest emoji set 321 | emojiSetCode === 't' 322 | // Don't replace the same URL in a loop 323 | || url.includes('#replaced') 324 | // Ignore non-png files 325 | || characterCodeEnd === -1 326 | // Messenger 1.0 and Facebook 2.2 emoji sets support only emoji up to version 5.0. 327 | // Fall back to default style for emoji >= 10.0 328 | || excludedEmoji.has(characterCode) 329 | ) { 330 | return {}; 331 | } 332 | 333 | const emojiSetPrefix = 'emoji.php/v9/'; 334 | const emojiSetIndex = url.indexOf(emojiSetPrefix) + emojiSetPrefix.length; 335 | const newURL 336 | = url.slice(0, emojiSetIndex) + emojiSetCode + url.slice(emojiSetIndex + 1) + '#replaced'; 337 | 338 | return {redirectURL: newURL}; 339 | } 340 | 341 | export async function generateSubmenu( 342 | updateMenu: () => Promise, 343 | ): Promise { 344 | const emojiMenuOption = async ( 345 | label: string, 346 | style: EmojiStyle, 347 | visibility: boolean, 348 | ): Promise => ({ 349 | label, 350 | type: 'checkbox', 351 | visible: visibility, 352 | icon: await getEmojiIcon(style), 353 | checked: config.get('emojiStyle') === style, 354 | async click() { 355 | if (config.get('emojiStyle') === style) { 356 | return; 357 | } 358 | 359 | config.set('emojiStyle', style); 360 | 361 | await updateMenu(); 362 | showRestartDialog('Caprine needs to be restarted to apply emoji changes.'); 363 | }, 364 | }); 365 | 366 | return Promise.all([ 367 | emojiMenuOption('System', EmojiStyle.Native, true), 368 | {type: 'separator'} as const, 369 | emojiMenuOption('Facebook 3.0', EmojiStyle.Facebook30, true), 370 | emojiMenuOption('Messenger 1.0', EmojiStyle.Messenger10, !is.linux || is.development), 371 | emojiMenuOption('Facebook 2.2', EmojiStyle.Facebook22, true), 372 | ]); 373 | } 374 | -------------------------------------------------------------------------------- /source/ensure-online.ts: -------------------------------------------------------------------------------- 1 | import {app, dialog} from 'electron'; 2 | import isOnline from 'is-online'; 3 | import pWaitFor from 'p-wait-for'; 4 | 5 | function showWaitDialog(): void { 6 | const buttonIndex = dialog.showMessageBoxSync({ 7 | message: 'You appear to be offline. Caprine requires a working internet connection.', 8 | detail: 'Do you want to wait?', 9 | buttons: [ 10 | 'Wait', 11 | 'Quit', 12 | ], 13 | defaultId: 0, 14 | cancelId: 1, 15 | }); 16 | 17 | if (buttonIndex === 1) { 18 | app.quit(); 19 | } 20 | } 21 | 22 | export default async (): Promise => { 23 | if (!(await isOnline())) { 24 | const connectivityTimeout = setTimeout(showWaitDialog, 15_000); 25 | 26 | await pWaitFor(isOnline, {interval: 1000}); 27 | clearTimeout(connectivityTimeout); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /source/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import {readFileSync, existsSync} from 'node:fs'; 3 | import { 4 | app, 5 | nativeImage, 6 | screen as electronScreen, 7 | session, 8 | shell, 9 | BrowserWindow, 10 | Menu, 11 | Notification, 12 | MenuItemConstructorOptions, 13 | systemPreferences, 14 | nativeTheme, 15 | } from 'electron'; 16 | import {ipcMain as ipc} from 'electron-better-ipc'; 17 | import {autoUpdater} from 'electron-updater'; 18 | import electronDl from 'electron-dl'; 19 | import electronContextMenu from 'electron-context-menu'; 20 | import electronLocalshortcut from 'electron-localshortcut'; 21 | import electronDebug from 'electron-debug'; 22 | import {is, darkMode} from 'electron-util'; 23 | import {bestFacebookLocaleFor} from 'facebook-locales'; 24 | import doNotDisturb from '@sindresorhus/do-not-disturb'; 25 | import updateAppMenu from './menu'; 26 | import config, {StoreType} from './config'; 27 | import tray from './tray'; 28 | import { 29 | sendAction, 30 | sendBackgroundAction, 31 | messengerDomain, 32 | stripTrackingFromUrl, 33 | } from './util'; 34 | import {process as processEmojiUrl} from './emoji'; 35 | import ensureOnline from './ensure-online'; 36 | import {setUpMenuBarMode} from './menu-bar-mode'; 37 | import {caprineIconPath} from './constants'; 38 | 39 | ipc.setMaxListeners(100); 40 | 41 | electronDebug({ 42 | isEnabled: true, // TODO: This is only enabled to allow `Command+R` because messenger.com sometimes gets stuck after computer waking up 43 | showDevTools: false, 44 | }); 45 | 46 | electronDl(); 47 | electronContextMenu({ 48 | showCopyImageAddress: true, 49 | prepend(defaultActions) { 50 | /* 51 | TODO: Use menu option or use replacement of options (https://github.com/sindresorhus/electron-context-menu/issues/70) 52 | See explanation for this hacky solution here: https://github.com/sindresorhus/caprine/pull/1169 53 | */ 54 | defaultActions.copyLink({ 55 | transform: stripTrackingFromUrl, 56 | }); 57 | 58 | return []; 59 | }, 60 | }); 61 | 62 | app.setAppUserModelId('com.sindresorhus.caprine'); 63 | 64 | if (!config.get('hardwareAcceleration')) { 65 | app.disableHardwareAcceleration(); 66 | } 67 | 68 | if (!is.development && config.get('autoUpdate')) { 69 | (async () => { 70 | const FOUR_HOURS = 1000 * 60 * 60 * 4; 71 | setInterval(async () => { 72 | await autoUpdater.checkForUpdatesAndNotify(); 73 | }, FOUR_HOURS); 74 | 75 | await autoUpdater.checkForUpdatesAndNotify(); 76 | })(); 77 | } 78 | 79 | let mainWindow: BrowserWindow; 80 | let isQuitting = false; 81 | let previousMessageCount = 0; 82 | let dockMenu: Menu; 83 | let isDNDEnabled = false; 84 | 85 | if (!app.requestSingleInstanceLock()) { 86 | app.quit(); 87 | } 88 | 89 | app.on('second-instance', () => { 90 | if (mainWindow) { 91 | if (mainWindow.isMinimized()) { 92 | mainWindow.restore(); 93 | } 94 | 95 | mainWindow.show(); 96 | } 97 | }); 98 | 99 | // Preserves the window position when a display is removed and Caprine is moved to a different screen. 100 | app.on('ready', () => { 101 | electronScreen.on('display-removed', () => { 102 | const [x, y] = mainWindow.getPosition(); 103 | mainWindow.setPosition(x, y); 104 | }); 105 | }); 106 | 107 | async function updateBadge(messageCount: number): Promise { 108 | if (!is.windows) { 109 | if (config.get('showUnreadBadge') && !isDNDEnabled) { 110 | app.badgeCount = messageCount; 111 | } 112 | 113 | if ( 114 | is.macos 115 | && !isDNDEnabled 116 | && config.get('bounceDockOnMessage') 117 | && previousMessageCount !== messageCount 118 | ) { 119 | app.dock.bounce('informational'); 120 | previousMessageCount = messageCount; 121 | } 122 | } 123 | 124 | if (!is.macos) { 125 | if (config.get('showUnreadBadge')) { 126 | tray.setBadge(messageCount > 0); 127 | } 128 | 129 | if (config.get('flashWindowOnMessage')) { 130 | mainWindow.flashFrame(messageCount !== 0); 131 | } 132 | } 133 | 134 | tray.update(messageCount); 135 | 136 | if (is.windows) { 137 | if (!config.get('showUnreadBadge') || messageCount === 0) { 138 | mainWindow.setOverlayIcon(null, ''); 139 | } else { 140 | // Delegate drawing of overlay icon to renderer process 141 | updateOverlayIcon(await ipc.callRenderer(mainWindow, 'render-overlay-icon', messageCount)); 142 | } 143 | } 144 | } 145 | 146 | function updateOverlayIcon({data, text}: {data: string; text: string}): void { 147 | const img = nativeImage.createFromDataURL(data); 148 | mainWindow.setOverlayIcon(img, text); 149 | } 150 | 151 | type BeforeSendHeadersResponse = { 152 | cancel?: boolean; 153 | requestHeaders?: Record; 154 | }; 155 | 156 | type OnSendHeadersDetails = { 157 | id: number; 158 | url: string; 159 | method: string; 160 | webContentsId?: number; 161 | resourceType: string; 162 | referrer: string; 163 | timestamp: number; 164 | requestHeaders: Record; 165 | }; 166 | 167 | function enableHiresResources(): void { 168 | const scaleFactor = Math.max( 169 | ...electronScreen.getAllDisplays().map(display => display.scaleFactor), 170 | ); 171 | 172 | if (scaleFactor === 1) { 173 | return; 174 | } 175 | 176 | const filter = {urls: [`*://*.${messengerDomain}/`]}; 177 | 178 | session.defaultSession.webRequest.onBeforeSendHeaders( 179 | filter, 180 | (details: OnSendHeadersDetails, callback: (response: BeforeSendHeadersResponse) => void) => { 181 | let cookie = details.requestHeaders.Cookie; 182 | 183 | if (cookie && details.method === 'GET') { 184 | cookie = /(?:; )?dpr=\d/.test(cookie) ? cookie.replace(/dpr=\d/, `dpr=${scaleFactor}`) : `${cookie}; dpr=${scaleFactor}`; 185 | 186 | (details.requestHeaders as any).Cookie = cookie; 187 | } 188 | 189 | callback({ 190 | cancel: false, 191 | requestHeaders: details.requestHeaders, 192 | }); 193 | }, 194 | ); 195 | } 196 | 197 | function initRequestsFiltering(): void { 198 | const filter = { 199 | urls: [ 200 | `*://*.${messengerDomain}/*typ.php*`, // Type indicator blocker 201 | `*://*.${messengerDomain}/*change_read_status.php*`, // Seen indicator blocker 202 | `*://*.${messengerDomain}/*delivery_receipts*`, // Delivery receipts indicator blocker 203 | `*://*.${messengerDomain}/*unread_threads*`, // Delivery receipts indicator blocker 204 | '*://*.fbcdn.net/images/emoji.php/v9/*', // Emoji 205 | '*://*.facebook.com/images/emoji.php/v9/*', // Emoji 206 | ], 207 | }; 208 | 209 | session.defaultSession.webRequest.onBeforeRequest(filter, async ({url}, callback) => { 210 | if (url.includes('emoji.php')) { 211 | callback(await processEmojiUrl(url)); 212 | } else if (url.includes('typ.php')) { 213 | callback({cancel: config.get('block.typingIndicator' as any)}); 214 | } else if (url.includes('change_read_status.php')) { 215 | callback({cancel: config.get('block.chatSeen' as any)}); 216 | } else if (url.includes('delivery_receipts') || url.includes('unread_threads')) { 217 | callback({cancel: config.get('block.deliveryReceipt' as any)}); 218 | } 219 | }); 220 | 221 | session.defaultSession.webRequest.onHeadersReceived({ 222 | urls: ['*://static.xx.fbcdn.net/rsrc.php/*'], 223 | }, ({responseHeaders}, callback) => { 224 | if (!config.get('callRingtoneMuted') || !responseHeaders) { 225 | callback({}); 226 | return; 227 | } 228 | 229 | const callRingtoneHash = '2NAu/QVqg211BbktgY5GkA=='; 230 | callback({ 231 | cancel: responseHeaders['content-md5'][0] === callRingtoneHash, 232 | }); 233 | }); 234 | } 235 | 236 | function setUserLocale(): void { 237 | const userLocale = bestFacebookLocaleFor(app.getLocale().replace('-', '_')); 238 | const cookie = { 239 | url: 'https://www.messenger.com/', 240 | name: 'locale', 241 | secure: true, 242 | value: userLocale, 243 | }; 244 | 245 | session.defaultSession.cookies.set(cookie); 246 | } 247 | 248 | function setNotificationsMute(status: boolean): void { 249 | const label = 'Mute Notifications'; 250 | const muteMenuItem = Menu.getApplicationMenu()!.getMenuItemById('mute-notifications')!; 251 | 252 | config.set('notificationsMuted', status); 253 | muteMenuItem.checked = status; 254 | 255 | if (is.macos) { 256 | const item = dockMenu.items.find(x => x.label === label); 257 | item!.checked = status; 258 | } 259 | } 260 | 261 | function createMainWindow(): BrowserWindow { 262 | const lastWindowState = config.get('lastWindowState'); 263 | 264 | // Messenger or Work Chat 265 | const mainURL = config.get('useWorkChat') 266 | ? 'https://work.facebook.com/chat' 267 | : 'https://www.messenger.com/login/'; 268 | 269 | const win = new BrowserWindow({ 270 | title: app.name, 271 | show: false, 272 | x: lastWindowState.x, 273 | y: lastWindowState.y, 274 | width: lastWindowState.width, 275 | height: lastWindowState.height, 276 | icon: is.linux ? caprineIconPath : undefined, 277 | minWidth: 400, 278 | minHeight: 200, 279 | alwaysOnTop: config.get('alwaysOnTop'), 280 | titleBarStyle: 'hiddenInset', 281 | trafficLightPosition: { 282 | x: 80, 283 | y: 20, 284 | }, 285 | autoHideMenuBar: config.get('autoHideMenuBar'), 286 | webPreferences: { 287 | preload: path.join(__dirname, 'browser.js'), 288 | contextIsolation: true, 289 | nodeIntegration: true, 290 | spellcheck: config.get('isSpellCheckerEnabled'), 291 | plugins: true, 292 | }, 293 | }); 294 | 295 | require('@electron/remote/main').initialize(); 296 | require('@electron/remote/main').enable(win.webContents); 297 | 298 | setUserLocale(); 299 | initRequestsFiltering(); 300 | 301 | let previousDarkMode = darkMode.isEnabled; 302 | darkMode.onChange(() => { 303 | if (darkMode.isEnabled !== previousDarkMode) { 304 | previousDarkMode = darkMode.isEnabled; 305 | win.webContents.send('set-theme'); 306 | } 307 | }); 308 | 309 | if (is.macos) { 310 | win.setSheetOffset(40); 311 | } 312 | 313 | win.loadURL(mainURL); 314 | 315 | win.on('close', event => { 316 | if (config.get('quitOnWindowClose')) { 317 | app.quit(); 318 | return; 319 | } 320 | 321 | // Workaround for https://github.com/electron/electron/issues/20263 322 | // Closing the app window when on full screen leaves a black screen 323 | // Exit fullscreen before closing 324 | if (is.macos && mainWindow.isFullScreen()) { 325 | mainWindow.once('leave-full-screen', () => { 326 | mainWindow.hide(); 327 | }); 328 | mainWindow.setFullScreen(false); 329 | } 330 | 331 | if (!isQuitting) { 332 | event.preventDefault(); 333 | 334 | // Workaround for https://github.com/electron/electron/issues/10023 335 | win.blur(); 336 | if (is.macos) { 337 | // On macOS we're using `app.hide()` in order to focus the previous window correctly 338 | app.hide(); 339 | } else { 340 | win.hide(); 341 | } 342 | } 343 | }); 344 | 345 | win.on('focus', () => { 346 | if (config.get('flashWindowOnMessage')) { 347 | // This is a security in the case where messageCount is not reset by page title update 348 | win.flashFrame(false); 349 | } 350 | }); 351 | 352 | win.on('resize', () => { 353 | const {isMaximized} = config.get('lastWindowState'); 354 | config.set('lastWindowState', {...win.getNormalBounds(), isMaximized}); 355 | }); 356 | 357 | win.on('maximize', () => { 358 | config.set('lastWindowState.isMaximized', true); 359 | }); 360 | 361 | win.on('unmaximize', () => { 362 | config.set('lastWindowState.isMaximized', false); 363 | }); 364 | 365 | return win; 366 | } 367 | 368 | (async () => { 369 | await Promise.all([ensureOnline(), app.whenReady()]); 370 | await updateAppMenu(); 371 | mainWindow = createMainWindow(); 372 | 373 | // Workaround for https://github.com/electron/electron/issues/5256 374 | electronLocalshortcut.register(mainWindow, 'CommandOrControl+=', () => { 375 | sendAction('zoom-in'); 376 | }); 377 | 378 | // Start in menu bar mode if enabled, otherwise start normally 379 | setUpMenuBarMode(mainWindow); 380 | 381 | if (is.macos) { 382 | const firstItem: MenuItemConstructorOptions = { 383 | label: 'Mute Notifications', 384 | type: 'checkbox', 385 | checked: config.get('notificationsMuted'), 386 | async click() { 387 | setNotificationsMute(await ipc.callRenderer(mainWindow, 'toggle-mute-notifications')); 388 | }, 389 | }; 390 | 391 | dockMenu = Menu.buildFromTemplate([firstItem]); 392 | app.dock.setMenu(dockMenu); 393 | 394 | // Dock icon is hidden initially on macOS 395 | if (config.get('showDockIcon')) { 396 | app.dock.show(); 397 | } 398 | 399 | ipc.once('conversations', () => { 400 | // Messenger sorts the conversations by unread state. 401 | // We select the first conversation from the list. 402 | sendAction('jump-to-conversation', 1); 403 | }); 404 | 405 | ipc.answerRenderer('conversations', (conversations: Conversation[]) => { 406 | if (conversations.length === 0) { 407 | return; 408 | } 409 | 410 | const items = conversations.map(({label, icon}, index) => ({ 411 | label: `${label}`, 412 | icon: nativeImage.createFromDataURL(icon), 413 | click() { 414 | mainWindow.show(); 415 | sendAction('jump-to-conversation', index + 1); 416 | }, 417 | })); 418 | 419 | app.dock.setMenu(Menu.buildFromTemplate([firstItem, {type: 'separator'}, ...items])); 420 | }); 421 | } 422 | 423 | // Update badge on conversations change 424 | ipc.answerRenderer('update-tray-icon', async (messageCount: number) => { 425 | updateBadge(messageCount); 426 | }); 427 | 428 | enableHiresResources(); 429 | 430 | const {webContents} = mainWindow; 431 | 432 | webContents.on('dom-ready', async () => { 433 | // Set window title to Caprine 434 | mainWindow.setTitle(app.name); 435 | 436 | await updateAppMenu(); 437 | 438 | const files = ['browser.css', 'dark-mode.css', 'vibrancy.css', 'code-blocks.css', 'autoplay.css', 'scrollbar.css']; 439 | 440 | const cssPath = path.join(__dirname, '..', 'css'); 441 | 442 | for (const file of files) { 443 | if (existsSync(path.join(cssPath, file))) { 444 | webContents.insertCSS(readFileSync(path.join(cssPath, file), 'utf8')); 445 | } 446 | } 447 | 448 | if (config.get('useWorkChat') && existsSync(path.join(cssPath, 'workchat.css'))) { 449 | webContents.insertCSS( 450 | readFileSync(path.join(cssPath, 'workchat.css'), 'utf8'), 451 | ); 452 | } 453 | 454 | if (existsSync(path.join(app.getPath('userData'), 'custom.css'))) { 455 | webContents.insertCSS(readFileSync(path.join(app.getPath('userData'), 'custom.css'), 'utf8')); 456 | } 457 | 458 | if (config.get('launchMinimized') || app.getLoginItemSettings().wasOpenedAsHidden) { 459 | mainWindow.hide(); 460 | tray.create(mainWindow); 461 | } else { 462 | if (config.get('lastWindowState').isMaximized) { 463 | mainWindow.maximize(); 464 | } 465 | 466 | mainWindow.show(); 467 | } 468 | 469 | if (is.macos) { 470 | ipc.answerRenderer('update-dnd-mode', async (initialSoundsValue: boolean) => { 471 | doNotDisturb.on('change', (doNotDisturb: boolean) => { 472 | isDNDEnabled = doNotDisturb; 473 | ipc.callRenderer(mainWindow, 'toggle-sounds', {checked: isDNDEnabled ? false : initialSoundsValue}); 474 | }); 475 | 476 | isDNDEnabled = await doNotDisturb.isEnabled(); 477 | 478 | return isDNDEnabled ? false : initialSoundsValue; 479 | }); 480 | } 481 | 482 | setNotificationsMute(await ipc.callRenderer(mainWindow, 'toggle-mute-notifications', { 483 | defaultStatus: config.get('notificationsMuted'), 484 | })); 485 | 486 | ipc.callRenderer(mainWindow, 'toggle-message-buttons', config.get('showMessageButtons')); 487 | 488 | await webContents.executeJavaScript( 489 | readFileSync(path.join(__dirname, 'notifications-isolated.js'), 'utf8'), 490 | ); 491 | 492 | if (is.macos) { 493 | await import('./touch-bar'); 494 | } 495 | }); 496 | 497 | webContents.setWindowOpenHandler(details => { 498 | if (details.disposition === 'foreground-tab' || details.disposition === 'background-tab') { 499 | const url = stripTrackingFromUrl(details.url); 500 | shell.openExternal(url); 501 | return {action: 'deny'}; 502 | } 503 | 504 | if (details.disposition === 'new-window') { 505 | if (details.url === 'about:blank' || details.url === 'about:blank#blocked') { 506 | if (details.frameName !== 'about:blank') { 507 | // Voice/video call popup 508 | return { 509 | action: 'allow', 510 | overrideBrowserWindowOptions: { 511 | show: true, 512 | titleBarStyle: 'default', 513 | webPreferences: { 514 | nodeIntegration: false, 515 | preload: path.join(__dirname, 'browser-call.js'), 516 | }, 517 | }, 518 | }; 519 | } 520 | } else { 521 | const url = stripTrackingFromUrl(details.url); 522 | shell.openExternal(url); 523 | } 524 | 525 | return {action: 'deny'}; 526 | } 527 | 528 | return {action: 'allow'}; 529 | }); 530 | 531 | webContents.on('will-navigate', async (event, url) => { 532 | const isMessengerDotCom = (url: string): boolean => { 533 | const {hostname} = new URL(url); 534 | return hostname.endsWith('.messenger.com'); 535 | }; 536 | 537 | const isTwoFactorAuth = (url: string): boolean => { 538 | const twoFactorAuthURL = 'https://www.facebook.com/checkpoint'; 539 | return url.startsWith(twoFactorAuthURL); 540 | }; 541 | 542 | const isWorkChat = (url: string): boolean => { 543 | const {hostname, pathname} = new URL(url); 544 | 545 | if (hostname === 'work.facebook.com' || hostname === 'work.workplace.com') { 546 | return true; 547 | } 548 | 549 | if ( 550 | // Example: https://company-name.facebook.com/login or 551 | // https://company-name.workplace.com/login 552 | (hostname.endsWith('.facebook.com') || hostname.endsWith('.workplace.com')) 553 | && (pathname.startsWith('/login') || pathname.startsWith('/chat')) 554 | ) { 555 | return true; 556 | } 557 | 558 | if (hostname === 'login.microsoftonline.com') { 559 | return true; 560 | } 561 | 562 | return false; 563 | }; 564 | 565 | if (isMessengerDotCom(url) || isTwoFactorAuth(url) || isWorkChat(url)) { 566 | return; 567 | } 568 | 569 | event.preventDefault(); 570 | await shell.openExternal(url); 571 | }); 572 | })(); 573 | 574 | if (is.macos) { 575 | ipc.answerRenderer('set-vibrancy', () => { 576 | mainWindow.setBackgroundColor('#80FFFFFF'); // Transparent, workaround for vibrancy issue. 577 | mainWindow.setVibrancy('sidebar'); 578 | }); 579 | } 580 | 581 | function toggleMaximized(): void { 582 | if (mainWindow.isMaximized()) { 583 | mainWindow.unmaximize(); 584 | } else { 585 | mainWindow.maximize(); 586 | } 587 | } 588 | 589 | ipc.answerRenderer('titlebar-doubleclick', () => { 590 | if (is.macos) { 591 | const doubleClickAction = systemPreferences.getUserDefault('AppleActionOnDoubleClick', 'string'); 592 | 593 | if (doubleClickAction === 'Minimize') { 594 | mainWindow.minimize(); 595 | } else if (doubleClickAction === 'Maximize') { 596 | toggleMaximized(); 597 | } 598 | } else { 599 | toggleMaximized(); 600 | } 601 | }); 602 | 603 | app.on('activate', () => { 604 | if (mainWindow) { 605 | mainWindow.show(); 606 | } 607 | }); 608 | 609 | app.on('before-quit', () => { 610 | isQuitting = true; 611 | 612 | // Checking whether the window exists to work around an Electron race issue: 613 | // https://github.com/sindresorhus/caprine/issues/809 614 | if (mainWindow) { 615 | const {isMaximized} = config.get('lastWindowState'); 616 | config.set('lastWindowState', {...mainWindow.getNormalBounds(), isMaximized}); 617 | } 618 | }); 619 | 620 | const notifications = new Map(); 621 | 622 | ipc.answerRenderer( 623 | 'notification', 624 | ({id, title, body, icon, silent}: {id: number; title: string; body: string; icon: string; silent: boolean}) => { 625 | // Don't send notifications when the window is focused 626 | if (mainWindow.isFocused()) { 627 | return; 628 | } 629 | 630 | const notification = new Notification({ 631 | title, 632 | body: config.get('notificationMessagePreview') ? body : 'You have a new message', 633 | hasReply: true, 634 | icon: nativeImage.createFromDataURL(icon), 635 | silent, 636 | }); 637 | 638 | notifications.set(id, notification); 639 | 640 | notification.on('click', () => { 641 | sendAction('notification-callback', {callbackName: 'onclick', id}); 642 | 643 | notifications.delete(id); 644 | }); 645 | 646 | notification.on('reply', (_event, reply: string) => { 647 | // We use onclick event used by messenger to go to the right convo 648 | sendBackgroundAction('notification-reply-callback', {callbackName: 'onclick', id, reply}); 649 | 650 | notifications.delete(id); 651 | }); 652 | 653 | notification.on('close', () => { 654 | sendBackgroundAction('notification-callback', {callbackName: 'onclose', id}); 655 | notifications.delete(id); 656 | }); 657 | 658 | notification.show(); 659 | }, 660 | ); 661 | 662 | type ThemeSource = typeof nativeTheme.themeSource; 663 | 664 | ipc.answerRenderer('get-config-useWorkChat', async () => config.get('useWorkChat')); 665 | ipc.answerRenderer('get-config-showMessageButtons', async () => config.get('showMessageButtons')); 666 | ipc.answerRenderer('get-config-theme', async () => config.get('theme')); 667 | ipc.answerRenderer('get-config-privateMode', async () => config.get('privateMode')); 668 | ipc.answerRenderer('get-config-vibrancy', async () => config.get('vibrancy')); 669 | ipc.answerRenderer('get-config-sidebar', async () => config.get('sidebar')); 670 | ipc.answerRenderer('get-config-zoomFactor', async () => config.get('zoomFactor')); 671 | ipc.answerRenderer('set-config-zoomFactor', async zoomFactor => { 672 | config.set('zoomFactor', zoomFactor); 673 | }); 674 | ipc.answerRenderer('get-config-keepMeSignedIn', async () => config.get('keepMeSignedIn')); 675 | ipc.answerRenderer('set-config-keepMeSignedIn', async keepMeSignedIn => { 676 | config.set('keepMeSignedIn', keepMeSignedIn); 677 | }); 678 | ipc.answerRenderer('get-config-autoplayVideos', async () => config.get('autoplayVideos')); 679 | ipc.answerRenderer('get-config-emojiStyle', async () => config.get('emojiStyle')); 680 | ipc.answerRenderer('set-config-emojiStyle', async emojiStyle => { 681 | config.set('emojiStyle', emojiStyle); 682 | }); 683 | -------------------------------------------------------------------------------- /source/menu-bar-mode.ts: -------------------------------------------------------------------------------- 1 | import { 2 | app, 3 | globalShortcut, 4 | BrowserWindow, 5 | Menu, 6 | } from 'electron'; 7 | import {is} from 'electron-util'; 8 | import config from './config'; 9 | import tray from './tray'; 10 | 11 | const menuBarShortcut = 'Command+Shift+y'; 12 | 13 | export function toggleMenuBarMode(window: BrowserWindow): void { 14 | const isEnabled = config.get('menuBarMode'); 15 | const menuItem = Menu.getApplicationMenu()!.getMenuItemById('menuBarMode')!; 16 | 17 | menuItem.checked = isEnabled; 18 | 19 | window.setVisibleOnAllWorkspaces(isEnabled); 20 | 21 | if (isEnabled) { 22 | globalShortcut.register(menuBarShortcut, () => { 23 | if (window.isVisible()) { 24 | window.hide(); 25 | } else { 26 | window.show(); 27 | } 28 | }); 29 | 30 | tray.create(window); 31 | } else { 32 | globalShortcut.unregister(menuBarShortcut); 33 | 34 | tray.destroy(); 35 | app.dock.show(); 36 | window.show(); 37 | } 38 | } 39 | 40 | export function setUpMenuBarMode(window: BrowserWindow): void { 41 | if (is.macos) { 42 | toggleMenuBarMode(window); 43 | } else if (config.get('showTrayIcon') && !config.get('quitOnWindowClose')) { 44 | tray.create(window); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /source/menu.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | import {existsSync, writeFileSync} from 'node:fs'; 3 | import { 4 | app, 5 | shell, 6 | Menu, 7 | MenuItemConstructorOptions, 8 | dialog, 9 | } from 'electron'; 10 | import { 11 | is, 12 | appMenu, 13 | openUrlMenuItem, 14 | aboutMenuItem, 15 | openNewGitHubIssue, 16 | debugInfo, 17 | } from 'electron-util'; 18 | import config from './config'; 19 | import getSpellCheckerLanguages from './spell-checker'; 20 | import { 21 | sendAction, 22 | showRestartDialog, 23 | getWindow, 24 | toggleTrayIcon, 25 | toggleLaunchMinimized, 26 | } from './util'; 27 | import {generateSubmenu as generateEmojiSubmenu} from './emoji'; 28 | import {toggleMenuBarMode} from './menu-bar-mode'; 29 | import {caprineIconPath} from './constants'; 30 | 31 | export default async function updateMenu(): Promise { 32 | const newConversationItem: MenuItemConstructorOptions = { 33 | label: 'New Conversation', 34 | accelerator: 'CommandOrControl+N', 35 | click() { 36 | sendAction('new-conversation'); 37 | }, 38 | }; 39 | 40 | const newRoomItem: MenuItemConstructorOptions = { 41 | label: 'New Room', 42 | accelerator: 'CommandOrControl+O', 43 | click() { 44 | sendAction('new-room'); 45 | }, 46 | }; 47 | 48 | const switchItems: MenuItemConstructorOptions[] = [ 49 | { 50 | label: 'Switch to Work Chat…', 51 | accelerator: 'CommandOrControl+Shift+2', 52 | visible: !config.get('useWorkChat'), 53 | click() { 54 | config.set('useWorkChat', true); 55 | app.relaunch(); 56 | app.quit(); 57 | }, 58 | }, 59 | { 60 | label: 'Switch to Messenger…', 61 | accelerator: 'CommandOrControl+Shift+1', 62 | visible: config.get('useWorkChat'), 63 | click() { 64 | config.set('useWorkChat', false); 65 | app.relaunch(); 66 | app.quit(); 67 | }, 68 | }, 69 | { 70 | label: 'Log Out', 71 | click() { 72 | sendAction('log-out'); 73 | }, 74 | }, 75 | ]; 76 | 77 | const vibrancySubmenu: MenuItemConstructorOptions[] = [ 78 | { 79 | label: 'No Vibrancy', 80 | type: 'checkbox', 81 | checked: config.get('vibrancy') === 'none', 82 | async click() { 83 | config.set('vibrancy', 'none'); 84 | sendAction('update-vibrancy'); 85 | await updateMenu(); 86 | }, 87 | }, 88 | { 89 | label: 'Sidebar-only Vibrancy', 90 | type: 'checkbox', 91 | checked: config.get('vibrancy') === 'sidebar', 92 | async click() { 93 | config.set('vibrancy', 'sidebar'); 94 | sendAction('update-vibrancy'); 95 | await updateMenu(); 96 | }, 97 | }, 98 | { 99 | label: 'Full-window Vibrancy', 100 | type: 'checkbox', 101 | checked: config.get('vibrancy') === 'full', 102 | async click() { 103 | config.set('vibrancy', 'full'); 104 | sendAction('update-vibrancy'); 105 | await updateMenu(); 106 | }, 107 | }, 108 | ]; 109 | 110 | const themeSubmenu: MenuItemConstructorOptions[] = [ 111 | { 112 | label: 'Follow System Appearance', 113 | type: 'checkbox', 114 | checked: config.get('theme') === 'system', 115 | async click() { 116 | config.set('theme', 'system'); 117 | sendAction('set-theme'); 118 | await updateMenu(); 119 | }, 120 | }, 121 | { 122 | label: 'Light Mode', 123 | type: 'checkbox', 124 | checked: config.get('theme') === 'light', 125 | async click() { 126 | config.set('theme', 'light'); 127 | sendAction('set-theme'); 128 | await updateMenu(); 129 | }, 130 | }, 131 | { 132 | label: 'Dark Mode', 133 | type: 'checkbox', 134 | checked: config.get('theme') === 'dark', 135 | async click() { 136 | config.set('theme', 'dark'); 137 | sendAction('set-theme'); 138 | await updateMenu(); 139 | }, 140 | }, 141 | ]; 142 | 143 | const sidebarSubmenu: MenuItemConstructorOptions[] = [ 144 | { 145 | label: 'Adaptive Sidebar', 146 | type: 'checkbox', 147 | checked: config.get('sidebar') === 'default', 148 | async click() { 149 | config.set('sidebar', 'default'); 150 | sendAction('update-sidebar'); 151 | await updateMenu(); 152 | }, 153 | }, 154 | { 155 | label: 'Hide Sidebar', 156 | type: 'checkbox', 157 | checked: config.get('sidebar') === 'hidden', 158 | accelerator: 'CommandOrControl+Shift+S', 159 | async click() { 160 | // Toggle between default and hidden 161 | config.set('sidebar', config.get('sidebar') === 'hidden' ? 'default' : 'hidden'); 162 | sendAction('update-sidebar'); 163 | await updateMenu(); 164 | }, 165 | }, 166 | { 167 | label: 'Narrow Sidebar', 168 | type: 'checkbox', 169 | checked: config.get('sidebar') === 'narrow', 170 | async click() { 171 | config.set('sidebar', 'narrow'); 172 | sendAction('update-sidebar'); 173 | await updateMenu(); 174 | }, 175 | }, 176 | { 177 | label: 'Wide Sidebar', 178 | type: 'checkbox', 179 | checked: config.get('sidebar') === 'wide', 180 | async click() { 181 | config.set('sidebar', 'wide'); 182 | sendAction('update-sidebar'); 183 | await updateMenu(); 184 | }, 185 | }, 186 | ]; 187 | 188 | const privacySubmenu: MenuItemConstructorOptions[] = [ 189 | { 190 | label: 'Block Seen Indicator', 191 | type: 'checkbox', 192 | checked: config.get('block.chatSeen' as any), 193 | click(menuItem) { 194 | config.set('block.chatSeen' as any, menuItem.checked); 195 | }, 196 | }, 197 | { 198 | label: 'Block Typing Indicator', 199 | type: 'checkbox', 200 | checked: config.get('block.typingIndicator' as any), 201 | click(menuItem) { 202 | config.set('block.typingIndicator' as any, menuItem.checked); 203 | }, 204 | }, 205 | { 206 | label: 'Block Delivery Receipts', 207 | type: 'checkbox', 208 | checked: config.get('block.deliveryReceipt' as any), 209 | click(menuItem) { 210 | config.set('block.deliveryReceipt' as any, menuItem.checked); 211 | }, 212 | }, 213 | ]; 214 | 215 | const advancedSubmenu: MenuItemConstructorOptions[] = [ 216 | { 217 | label: 'Custom Styles', 218 | click() { 219 | const filePath = path.join(app.getPath('userData'), 'custom.css'); 220 | const defaultCustomStyle = `/* 221 | This is the custom styles file where you can add anything you want. 222 | The styles here will be injected into Caprine and will override default styles. 223 | If you want to disable styles but keep the config, just comment the lines that you don't want to be used. 224 | 225 | Press Command/Ctrl+R in Caprine to see your changes. 226 | */ 227 | `; 228 | 229 | if (!existsSync(filePath)) { 230 | writeFileSync(filePath, defaultCustomStyle, 'utf8'); 231 | } 232 | 233 | shell.openPath(filePath); 234 | }, 235 | }, 236 | ]; 237 | 238 | const preferencesSubmenu: MenuItemConstructorOptions[] = [ 239 | { 240 | /* TODO: Fix privacy features */ 241 | /* If you want to help, see #1688 */ 242 | label: 'Privacy', 243 | visible: is.development, 244 | submenu: privacySubmenu, 245 | }, 246 | { 247 | label: 'Emoji Style', 248 | submenu: await generateEmojiSubmenu(updateMenu), 249 | }, 250 | { 251 | label: 'Bounce Dock on Message', 252 | type: 'checkbox', 253 | visible: is.macos, 254 | checked: config.get('bounceDockOnMessage'), 255 | click() { 256 | config.set('bounceDockOnMessage', !config.get('bounceDockOnMessage')); 257 | }, 258 | }, 259 | { 260 | /* TODO: Fix ability to disable autoplay */ 261 | /* GitHub issue: #1845 */ 262 | label: 'Autoplay Videos', 263 | id: 'video-autoplay', 264 | type: 'checkbox', 265 | visible: is.development, 266 | checked: config.get('autoplayVideos'), 267 | click() { 268 | config.set('autoplayVideos', !config.get('autoplayVideos')); 269 | sendAction('toggle-video-autoplay'); 270 | }, 271 | }, 272 | { 273 | /* TODO: Fix notifications */ 274 | label: 'Show Message Preview in Notifications', 275 | type: 'checkbox', 276 | visible: is.development, 277 | checked: config.get('notificationMessagePreview'), 278 | click(menuItem) { 279 | config.set('notificationMessagePreview', menuItem.checked); 280 | }, 281 | }, 282 | { 283 | /* TODO: Fix notifications */ 284 | label: 'Mute Notifications', 285 | id: 'mute-notifications', 286 | type: 'checkbox', 287 | visible: is.development, 288 | checked: config.get('notificationsMuted'), 289 | click() { 290 | sendAction('toggle-mute-notifications'); 291 | }, 292 | }, 293 | { 294 | label: 'Mute Call Ringtone', 295 | type: 'checkbox', 296 | checked: config.get('callRingtoneMuted'), 297 | click() { 298 | config.set('callRingtoneMuted', !config.get('callRingtoneMuted')); 299 | }, 300 | }, 301 | { 302 | /* TODO: Fix notification badge */ 303 | label: 'Show Unread Badge', 304 | type: 'checkbox', 305 | visible: is.development, 306 | checked: config.get('showUnreadBadge'), 307 | click() { 308 | config.set('showUnreadBadge', !config.get('showUnreadBadge')); 309 | sendAction('reload'); 310 | }, 311 | }, 312 | { 313 | label: 'Spell Checker', 314 | type: 'checkbox', 315 | checked: config.get('isSpellCheckerEnabled'), 316 | click() { 317 | config.set('isSpellCheckerEnabled', !config.get('isSpellCheckerEnabled')); 318 | showRestartDialog('Caprine needs to be restarted to enable or disable the spell checker.'); 319 | }, 320 | }, 321 | { 322 | label: 'Hardware Acceleration', 323 | type: 'checkbox', 324 | checked: config.get('hardwareAcceleration'), 325 | click() { 326 | config.set('hardwareAcceleration', !config.get('hardwareAcceleration')); 327 | showRestartDialog('Caprine needs to be restarted to change hardware acceleration.'); 328 | }, 329 | }, 330 | { 331 | label: 'Show Menu Bar Icon', 332 | id: 'menuBarMode', 333 | type: 'checkbox', 334 | visible: is.macos, 335 | checked: config.get('menuBarMode'), 336 | click() { 337 | config.set('menuBarMode', !config.get('menuBarMode')); 338 | toggleMenuBarMode(getWindow()); 339 | }, 340 | }, 341 | { 342 | label: 'Always on Top', 343 | id: 'always-on-top', 344 | type: 'checkbox', 345 | accelerator: 'CommandOrControl+Shift+T', 346 | checked: config.get('alwaysOnTop'), 347 | async click(menuItem, focusedWindow, event) { 348 | if (!config.get('alwaysOnTop') && config.get('showAlwaysOnTopPrompt') && event.shiftKey) { 349 | const result = await dialog.showMessageBox(focusedWindow!, { 350 | message: 'Are you sure you want the window to stay on top of other windows?', 351 | detail: 'This was triggered by Command/Control+Shift+T.', 352 | buttons: [ 353 | 'Display on Top', 354 | 'Don\'t Display on Top', 355 | ], 356 | defaultId: 0, 357 | cancelId: 1, 358 | checkboxLabel: 'Don\'t ask me again', 359 | }); 360 | 361 | config.set('showAlwaysOnTopPrompt', !result.checkboxChecked); 362 | 363 | if (result.response === 0) { 364 | config.set('alwaysOnTop', !config.get('alwaysOnTop')); 365 | focusedWindow?.setAlwaysOnTop(menuItem.checked); 366 | } else if (result.response === 1) { 367 | menuItem.checked = false; 368 | } 369 | } else { 370 | config.set('alwaysOnTop', !config.get('alwaysOnTop')); 371 | focusedWindow?.setAlwaysOnTop(menuItem.checked); 372 | } 373 | }, 374 | }, 375 | { 376 | /* TODO: Add support for Linux */ 377 | label: 'Launch at Login', 378 | visible: !is.linux, 379 | type: 'checkbox', 380 | checked: app.getLoginItemSettings().openAtLogin, 381 | click(menuItem) { 382 | app.setLoginItemSettings({ 383 | openAtLogin: menuItem.checked, 384 | openAsHidden: menuItem.checked, 385 | }); 386 | }, 387 | }, 388 | { 389 | label: 'Auto Hide Menu Bar', 390 | type: 'checkbox', 391 | visible: !is.macos, 392 | checked: config.get('autoHideMenuBar'), 393 | click(menuItem, focusedWindow) { 394 | config.set('autoHideMenuBar', menuItem.checked); 395 | focusedWindow?.setAutoHideMenuBar(menuItem.checked); 396 | focusedWindow?.setMenuBarVisibility(!menuItem.checked); 397 | 398 | if (menuItem.checked) { 399 | dialog.showMessageBox({ 400 | type: 'info', 401 | message: 'Press the Alt key to toggle the menu bar.', 402 | buttons: ['OK'], 403 | }); 404 | } 405 | }, 406 | }, 407 | { 408 | label: 'Automatic Updates', 409 | type: 'checkbox', 410 | checked: config.get('autoUpdate'), 411 | click() { 412 | config.set('autoUpdate', !config.get('autoUpdate')); 413 | }, 414 | }, 415 | { 416 | /* TODO: Fix notifications */ 417 | label: 'Flash Window on Message', 418 | type: 'checkbox', 419 | visible: is.development, 420 | checked: config.get('flashWindowOnMessage'), 421 | click(menuItem) { 422 | config.set('flashWindowOnMessage', menuItem.checked); 423 | }, 424 | }, 425 | { 426 | id: 'showTrayIcon', 427 | label: 'Show Tray Icon', 428 | type: 'checkbox', 429 | enabled: !is.macos && !config.get('launchMinimized'), 430 | checked: config.get('showTrayIcon'), 431 | click() { 432 | toggleTrayIcon(); 433 | }, 434 | }, 435 | { 436 | label: 'Launch Minimized', 437 | type: 'checkbox', 438 | visible: !is.macos, 439 | checked: config.get('launchMinimized'), 440 | click() { 441 | toggleLaunchMinimized(menu); 442 | }, 443 | }, 444 | { 445 | label: 'Quit on Window Close', 446 | type: 'checkbox', 447 | checked: config.get('quitOnWindowClose'), 448 | click() { 449 | config.set('quitOnWindowClose', !config.get('quitOnWindowClose')); 450 | }, 451 | }, 452 | { 453 | type: 'separator', 454 | }, 455 | { 456 | label: 'Advanced', 457 | submenu: advancedSubmenu, 458 | }, 459 | ]; 460 | 461 | const viewSubmenu: MenuItemConstructorOptions[] = [ 462 | { 463 | label: 'Reset Text Size', 464 | accelerator: 'CommandOrControl+0', 465 | click() { 466 | sendAction('zoom-reset'); 467 | }, 468 | }, 469 | { 470 | label: 'Increase Text Size', 471 | accelerator: 'CommandOrControl+Plus', 472 | click() { 473 | sendAction('zoom-in'); 474 | }, 475 | }, 476 | { 477 | label: 'Decrease Text Size', 478 | accelerator: 'CommandOrControl+-', 479 | click() { 480 | sendAction('zoom-out'); 481 | }, 482 | }, 483 | { 484 | type: 'separator', 485 | }, 486 | { 487 | label: 'Theme', 488 | submenu: themeSubmenu, 489 | }, 490 | { 491 | label: 'Vibrancy', 492 | visible: is.macos, 493 | submenu: vibrancySubmenu, 494 | }, 495 | { 496 | type: 'separator', 497 | }, 498 | { 499 | label: 'Hide Names and Avatars', 500 | id: 'privateMode', 501 | type: 'checkbox', 502 | checked: config.get('privateMode'), 503 | accelerator: 'CommandOrControl+Shift+N', 504 | async click(menuItem, _browserWindow, event) { 505 | if (!config.get('privateMode') && config.get('showPrivateModePrompt') && event.shiftKey) { 506 | const result = await dialog.showMessageBox(_browserWindow!, { 507 | message: 'Are you sure you want to hide names and avatars?', 508 | detail: 'This was triggered by Command/Control+Shift+N.', 509 | buttons: [ 510 | 'Hide', 511 | 'Don\'t Hide', 512 | ], 513 | defaultId: 0, 514 | cancelId: 1, 515 | checkboxLabel: 'Don\'t ask me again', 516 | }); 517 | 518 | config.set('showPrivateModePrompt', !result.checkboxChecked); 519 | 520 | if (result.response === 0) { 521 | config.set('privateMode', !config.get('privateMode')); 522 | sendAction('set-private-mode'); 523 | } else if (result.response === 1) { 524 | menuItem.checked = false; 525 | } 526 | } else { 527 | config.set('privateMode', !config.get('privateMode')); 528 | sendAction('set-private-mode'); 529 | } 530 | }, 531 | }, 532 | { 533 | type: 'separator', 534 | }, 535 | { 536 | label: 'Sidebar', 537 | submenu: sidebarSubmenu, 538 | }, 539 | { 540 | label: 'Show Message Buttons', 541 | type: 'checkbox', 542 | checked: config.get('showMessageButtons'), 543 | click() { 544 | config.set('showMessageButtons', !config.get('showMessageButtons')); 545 | sendAction('toggle-message-buttons'); 546 | }, 547 | }, 548 | { 549 | type: 'separator', 550 | }, 551 | { 552 | label: 'Show Main Chats', 553 | click() { 554 | sendAction('show-chats-view'); 555 | }, 556 | }, 557 | { 558 | label: 'Show Marketplace Chats', 559 | click() { 560 | sendAction('show-marketplace-view'); 561 | }, 562 | }, 563 | { 564 | label: 'Show Message Requests', 565 | click() { 566 | sendAction('show-requests-view'); 567 | }, 568 | }, 569 | { 570 | label: 'Show Archived Chats', 571 | click() { 572 | sendAction('show-archive-view'); 573 | }, 574 | }, 575 | ]; 576 | 577 | const spellCheckerSubmenu: MenuItemConstructorOptions[] = getSpellCheckerLanguages(); 578 | 579 | const conversationSubmenu: MenuItemConstructorOptions[] = [ 580 | { 581 | label: 'Mute Conversation', 582 | accelerator: 'CommandOrControl+Shift+M', 583 | click() { 584 | sendAction('mute-conversation'); 585 | }, 586 | }, 587 | { 588 | label: 'Archive Conversation', 589 | accelerator: 'CommandOrControl+Shift+H', 590 | click() { 591 | sendAction('archive-conversation'); 592 | }, 593 | }, 594 | { 595 | label: 'Delete Conversation', 596 | accelerator: 'CommandOrControl+Shift+D', 597 | click() { 598 | sendAction('delete-conversation'); 599 | }, 600 | }, 601 | { 602 | label: 'Select Next Conversation', 603 | accelerator: 'Control+Tab', 604 | click() { 605 | sendAction('next-conversation'); 606 | }, 607 | }, 608 | { 609 | label: 'Select Previous Conversation', 610 | accelerator: 'Control+Shift+Tab', 611 | click() { 612 | sendAction('previous-conversation'); 613 | }, 614 | }, 615 | { 616 | label: 'Find Conversation', 617 | accelerator: 'CommandOrControl+K', 618 | click() { 619 | sendAction('find'); 620 | }, 621 | }, 622 | { 623 | label: 'Search in Conversation', 624 | accelerator: 'CommandOrControl+F', 625 | click() { 626 | sendAction('search'); 627 | }, 628 | }, 629 | { 630 | label: 'Insert GIF', 631 | accelerator: 'CommandOrControl+G', 632 | click() { 633 | sendAction('insert-gif'); 634 | }, 635 | }, 636 | { 637 | label: 'Insert Sticker', 638 | accelerator: 'CommandOrControl+S', 639 | click() { 640 | sendAction('insert-sticker'); 641 | }, 642 | }, 643 | { 644 | label: 'Insert Emoji', 645 | accelerator: 'CommandOrControl+E', 646 | click() { 647 | sendAction('insert-emoji'); 648 | }, 649 | }, 650 | { 651 | label: 'Attach Files', 652 | accelerator: 'CommandOrControl+T', 653 | click() { 654 | sendAction('attach-files'); 655 | }, 656 | }, 657 | { 658 | label: 'Focus Text Input', 659 | accelerator: 'CommandOrControl+I', 660 | click() { 661 | sendAction('focus-text-input'); 662 | }, 663 | }, 664 | { 665 | type: 'separator', 666 | }, 667 | { 668 | label: 'Spell Checker Language', 669 | visible: !is.macos && config.get('isSpellCheckerEnabled'), 670 | submenu: spellCheckerSubmenu, 671 | }, 672 | ]; 673 | 674 | const helpSubmenu: MenuItemConstructorOptions[] = [ 675 | openUrlMenuItem({ 676 | label: 'Website', 677 | url: 'https://github.com/sindresorhus/caprine', 678 | }), 679 | openUrlMenuItem({ 680 | label: 'Source Code', 681 | url: 'https://github.com/sindresorhus/caprine', 682 | }), 683 | openUrlMenuItem({ 684 | label: 'Donate…', 685 | url: 'https://github.com/sindresorhus/caprine?sponsor=1', 686 | }), 687 | { 688 | label: 'Report an Issue…', 689 | click() { 690 | const body = ` 691 | 692 | 693 | 694 | --- 695 | 696 | ${debugInfo()}`; 697 | 698 | openNewGitHubIssue({ 699 | user: 'sindresorhus', 700 | repo: 'caprine', 701 | body, 702 | }); 703 | }, 704 | }, 705 | ]; 706 | 707 | if (!is.macos) { 708 | helpSubmenu.push( 709 | { 710 | type: 'separator', 711 | }, 712 | aboutMenuItem({ 713 | icon: caprineIconPath, 714 | copyright: 'Created by Sindre Sorhus', 715 | text: 'Maintainers:\nDušan Simić\nLefteris Garyfalakis\nMichael Quevillon\nNikolas Spiridakis', 716 | website: 'https://github.com/sindresorhus/caprine', 717 | }), 718 | ); 719 | } 720 | 721 | const debugSubmenu: MenuItemConstructorOptions[] = [ 722 | { 723 | label: 'Show Settings', 724 | click() { 725 | config.openInEditor(); 726 | }, 727 | }, 728 | { 729 | label: 'Show App Data', 730 | click() { 731 | shell.openPath(app.getPath('userData')); 732 | }, 733 | }, 734 | { 735 | type: 'separator', 736 | }, 737 | { 738 | label: 'Delete Settings', 739 | click() { 740 | config.clear(); 741 | app.relaunch(); 742 | app.quit(); 743 | }, 744 | }, 745 | { 746 | label: 'Delete App Data', 747 | click() { 748 | shell.trashItem(app.getPath('userData')); 749 | app.relaunch(); 750 | app.quit(); 751 | }, 752 | }, 753 | ]; 754 | 755 | const macosTemplate: MenuItemConstructorOptions[] = [ 756 | appMenu([ 757 | { 758 | label: 'Caprine Preferences', 759 | submenu: preferencesSubmenu, 760 | }, 761 | { 762 | label: 'Messenger Preferences…', 763 | accelerator: 'Command+,', 764 | click() { 765 | sendAction('show-preferences'); 766 | }, 767 | }, 768 | { 769 | type: 'separator', 770 | }, 771 | ...switchItems, 772 | { 773 | type: 'separator', 774 | }, 775 | { 776 | label: 'Relaunch Caprine', 777 | click() { 778 | app.relaunch(); 779 | app.quit(); 780 | }, 781 | }, 782 | ]), 783 | { 784 | role: 'fileMenu', 785 | submenu: [ 786 | newConversationItem, 787 | newRoomItem, 788 | { 789 | type: 'separator', 790 | }, 791 | { 792 | role: 'close', 793 | }, 794 | ], 795 | }, 796 | { 797 | role: 'editMenu', 798 | }, 799 | { 800 | role: 'viewMenu', 801 | submenu: viewSubmenu, 802 | }, 803 | { 804 | label: 'Conversation', 805 | submenu: conversationSubmenu, 806 | }, 807 | { 808 | role: 'windowMenu', 809 | }, 810 | { 811 | role: 'help', 812 | submenu: helpSubmenu, 813 | }, 814 | ]; 815 | 816 | const linuxWindowsTemplate: MenuItemConstructorOptions[] = [ 817 | { 818 | role: 'fileMenu', 819 | submenu: [ 820 | newConversationItem, 821 | newRoomItem, 822 | { 823 | type: 'separator', 824 | }, 825 | { 826 | label: 'Caprine Settings', 827 | submenu: preferencesSubmenu, 828 | }, 829 | { 830 | label: 'Messenger Settings', 831 | accelerator: 'Control+,', 832 | click() { 833 | sendAction('show-preferences'); 834 | }, 835 | }, 836 | { 837 | type: 'separator', 838 | }, 839 | ...switchItems, 840 | { 841 | type: 'separator', 842 | }, 843 | { 844 | label: 'Relaunch Caprine', 845 | click() { 846 | app.relaunch(); 847 | app.quit(); 848 | }, 849 | }, 850 | { 851 | role: 'quit', 852 | }, 853 | ], 854 | }, 855 | { 856 | role: 'editMenu', 857 | }, 858 | { 859 | role: 'viewMenu', 860 | submenu: viewSubmenu, 861 | }, 862 | { 863 | label: 'Conversation', 864 | submenu: conversationSubmenu, 865 | }, 866 | { 867 | role: 'help', 868 | submenu: helpSubmenu, 869 | }, 870 | ]; 871 | 872 | const template = is.macos ? macosTemplate : linuxWindowsTemplate; 873 | 874 | if (is.development) { 875 | template.push({ 876 | label: 'Debug', 877 | submenu: debugSubmenu, 878 | }); 879 | } 880 | 881 | const menu = Menu.buildFromTemplate(template); 882 | Menu.setApplicationMenu(menu); 883 | 884 | return menu; 885 | } 886 | -------------------------------------------------------------------------------- /source/notification-event.d.ts: -------------------------------------------------------------------------------- 1 | type NotificationEvent = { 2 | id: number; 3 | title: string; 4 | body: string; 5 | icon: string; 6 | silent: boolean; 7 | }; 8 | -------------------------------------------------------------------------------- /source/notifications-isolated.ts: -------------------------------------------------------------------------------- 1 | ((window, notification) => { 2 | const notifications = new Map(); 3 | 4 | // Handle events sent from the browser process 5 | window.addEventListener('message', ({data: {type, data}}) => { 6 | if (type === 'notification-callback') { 7 | const {callbackName, id}: NotificationCallback = data; 8 | const notification = notifications.get(id); 9 | 10 | if (!notification) { 11 | return; 12 | } 13 | 14 | if (notification[callbackName]) { 15 | notification[callbackName](); 16 | } 17 | 18 | if (callbackName === 'onclose') { 19 | notifications.delete(id); 20 | } 21 | } 22 | 23 | if (type === 'notification-reply-callback') { 24 | const {callbackName, id, previousConversation, reply}: NotificationReplyCallback = data; 25 | const notification = notifications.get(id); 26 | 27 | if (!notification) { 28 | return; 29 | } 30 | 31 | if (notification[callbackName]) { 32 | notification[callbackName](); 33 | } 34 | 35 | notifications.delete(id); 36 | window.postMessage({type: 'notification-reply', data: {previousConversation, reply}}, '*'); 37 | } 38 | }); 39 | 40 | let counter = 1; 41 | 42 | const augmentedNotification = Object.assign( 43 | class { 44 | private readonly _id: number; 45 | 46 | constructor(title: string, options: NotificationOptions) { 47 | // According to https://github.com/sindresorhus/caprine/pull/637, the Notification 48 | // constructor can be called with non-string title and body. 49 | let {body} = options; 50 | const bodyProperties = (body as any).props; 51 | body = bodyProperties ? bodyProperties.content[0] : options.body; 52 | 53 | const titleProperties = (title as any).props; 54 | title = titleProperties ? titleProperties.content[0] : title; 55 | 56 | this._id = counter++; 57 | 58 | notifications.set(this._id, this as any); 59 | 60 | window.postMessage( 61 | { 62 | type: 'notification', 63 | data: { 64 | title, 65 | id: this._id, 66 | ...options, 67 | body, 68 | }, 69 | }, 70 | '*', 71 | ); 72 | } 73 | 74 | // No-op, but Messenger expects this method to be present 75 | close(): void {} // eslint-disable-line @typescript-eslint/no-empty-function 76 | }, 77 | notification, 78 | ); 79 | 80 | Object.assign(window, {notification: augmentedNotification}); 81 | })(window, Notification); 82 | -------------------------------------------------------------------------------- /source/notifications.d.ts: -------------------------------------------------------------------------------- 1 | type NotificationCallback = { 2 | callbackName: keyof Notification; 3 | id: number; 4 | }; 5 | 6 | type NotificationReplyCallback = NotificationCallback & { 7 | previousConversation: number; 8 | reply: string; 9 | }; 10 | -------------------------------------------------------------------------------- /source/spell-checker.ts: -------------------------------------------------------------------------------- 1 | import {session, MenuItemConstructorOptions} from 'electron'; 2 | import config from './config'; 3 | 4 | const languageToCode = new Map([ 5 | // All languages available in Electron's spellchecker 6 | ['af', 'Afrikaans'], 7 | ['bg', 'Bulgarian'], 8 | ['ca', 'Catalan'], 9 | ['cs', 'Czech'], 10 | ['cy', 'Welsh'], 11 | ['da', 'Danish '], 12 | ['de', 'German'], 13 | ['el', 'Greek'], 14 | ['en', 'English'], 15 | ['en-AU', 'English (Australia)'], 16 | ['en-CA', 'English (Canada)'], 17 | ['en-GB', 'English (United Kingdom)'], 18 | ['en-US', 'English (United States)'], 19 | ['es', 'Spanish'], 20 | ['es-ES', 'Spanish'], 21 | ['es-419', 'Spanish (Central and South America)'], 22 | ['es-AR', 'Spanish (Argentina)'], 23 | ['es-MX', 'Spanish (Mexico)'], 24 | ['es-US', 'Spanish (United States)'], 25 | ['et', 'Estonian'], 26 | ['fa', 'Persian'], 27 | ['fo', 'Faroese'], 28 | ['fr', 'French'], 29 | ['he', 'Hebrew'], 30 | ['hi', 'Hindi'], 31 | ['hr', 'Croatian'], 32 | ['hu', 'Hungarian'], 33 | ['hy', 'Armenian'], 34 | ['id', 'Indonesian'], 35 | ['it', 'Italian'], 36 | ['ko', 'Korean'], 37 | ['lt', 'Lithuanian'], 38 | ['lv', 'Latvian'], 39 | ['nb', 'Norwegian'], 40 | ['nl', 'Dutch'], 41 | ['pl', 'Polish'], 42 | ['pt', 'Portuguese'], 43 | ['pt-BR', 'Portuguese (Brazil)'], 44 | ['pt-PT', 'Portuguese'], 45 | ['ro', 'Moldovan'], 46 | ['ru', 'Russian'], 47 | ['sh', 'Serbo-Croatian'], 48 | ['sk', 'Slovak'], 49 | ['sl', 'Slovenian'], 50 | ['sq', 'Albanian'], 51 | ['sr', 'Serbian'], 52 | ['sv', 'Swedish'], 53 | ['ta', 'Tamil'], 54 | ['tg', 'Tajik'], 55 | ['tr', 'Turkish'], 56 | ['uk', 'Ukrainian'], 57 | ['vi', 'Vietnamese'], 58 | ]); 59 | 60 | function getSpellCheckerLanguages(): MenuItemConstructorOptions[] { 61 | const availableLanguages = session.defaultSession.availableSpellCheckerLanguages; 62 | const languageItem: MenuItemConstructorOptions[] = []; 63 | let languagesChecked = config.get('spellCheckerLanguages'); 64 | 65 | for (const language of languagesChecked) { 66 | if (!availableLanguages.includes(language)) { 67 | // Remove it since it's not in the spell checker dictionary. 68 | languagesChecked = languagesChecked.filter(currentLang => currentLang !== language); 69 | config.set('spellCheckerLanguages', languagesChecked); 70 | } 71 | } 72 | 73 | for (const language of availableLanguages) { 74 | languageItem.push( 75 | { 76 | label: languageToCode.get(language) ?? languageToCode.get(language.split('-')[0]) ?? language, 77 | type: 'checkbox', 78 | checked: languagesChecked.includes(language), 79 | click() { 80 | const index = languagesChecked.indexOf(language); 81 | if (index > -1) { 82 | // Remove language 83 | languagesChecked.splice(index, 1); 84 | config.set('spellCheckerLanguages', languagesChecked); 85 | } else { 86 | // Add language 87 | languagesChecked = [...languagesChecked, language]; 88 | config.set('spellCheckerLanguages', languagesChecked); 89 | } 90 | 91 | session.defaultSession.setSpellCheckerLanguages(languagesChecked); 92 | }, 93 | }, 94 | ); 95 | } 96 | 97 | if (languageItem.length === 1) { 98 | return [ 99 | { 100 | label: 'System Default', 101 | type: 'checkbox', 102 | checked: true, 103 | enabled: false, 104 | }, 105 | ]; 106 | } 107 | 108 | return languageItem; 109 | } 110 | 111 | export default getSpellCheckerLanguages; 112 | -------------------------------------------------------------------------------- /source/touch-bar.ts: -------------------------------------------------------------------------------- 1 | import {TouchBar, nativeImage} from 'electron'; 2 | import {ipcMain as ipc} from 'electron-better-ipc'; 3 | import config from './config'; 4 | import {sendAction, getWindow} from './util'; 5 | import {caprineIconPath} from './constants'; 6 | 7 | const {TouchBarButton} = TouchBar; 8 | const MAX_VISIBLE_LENGTH = 25; 9 | const privateModeTouchBarLabel: Electron.TouchBarButton = new TouchBarButton({ 10 | label: 'Private mode enabled', 11 | icon: nativeImage.createFromPath(caprineIconPath), 12 | iconPosition: 'left', 13 | }); 14 | 15 | function setTouchBar(items: Electron.TouchBarButton[]): void { 16 | const touchBar = new TouchBar({items}); 17 | const win = getWindow(); 18 | win.setTouchBar(touchBar); 19 | } 20 | 21 | function createLabel(label: string): string { 22 | if (label.length > MAX_VISIBLE_LENGTH) { 23 | // If the label is too long, we'll render a truncated one with "…" appended 24 | return `${label.slice(0, MAX_VISIBLE_LENGTH)}…`; 25 | } 26 | 27 | return label; 28 | } 29 | 30 | function createTouchBarButton({label, selected, icon}: Conversation, index: number): Electron.TouchBarButton { 31 | return new TouchBarButton({ 32 | label: createLabel(label), 33 | backgroundColor: selected ? '#0084ff' : undefined, 34 | icon: nativeImage.createFromDataURL(icon), 35 | iconPosition: 'left', 36 | click() { 37 | sendAction('jump-to-conversation', index + 1); 38 | }, 39 | }); 40 | } 41 | 42 | ipc.answerRenderer('conversations', (conversations: Conversation[]) => { 43 | if (config.get('privateMode')) { 44 | setTouchBar([privateModeTouchBarLabel]); 45 | } else { 46 | setTouchBar(conversations.map((conversation, index) => createTouchBarButton(conversation, index))); 47 | } 48 | }); 49 | -------------------------------------------------------------------------------- /source/tray.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | import { 3 | app, 4 | Menu, 5 | Tray, 6 | BrowserWindow, 7 | MenuItemConstructorOptions, 8 | } from 'electron'; 9 | import {is} from 'electron-util'; 10 | import config from './config'; 11 | import {toggleMenuBarMode} from './menu-bar-mode'; 12 | 13 | let tray: Tray | undefined; 14 | let previousMessageCount = 0; 15 | 16 | let contextMenu: Menu; 17 | 18 | export default { 19 | create(win: BrowserWindow) { 20 | if (tray) { 21 | return; 22 | } 23 | 24 | function toggleWindow(): void { 25 | if (win.isVisible()) { 26 | win.hide(); 27 | } else { 28 | if (config.get('lastWindowState').isMaximized) { 29 | win.maximize(); 30 | win.focus(); 31 | } else { 32 | win.show(); 33 | } 34 | 35 | // Workaround for https://github.com/electron/electron/issues/20858 36 | // `setAlwaysOnTop` stops working after hiding the window on KDE Plasma. 37 | const alwaysOnTopMenuItem = Menu.getApplicationMenu()!.getMenuItemById('always-on-top')!; 38 | win.setAlwaysOnTop(alwaysOnTopMenuItem.checked); 39 | } 40 | } 41 | 42 | const macosMenuItems: MenuItemConstructorOptions[] = is.macos 43 | ? [ 44 | { 45 | label: 'Disable Menu Bar Mode', 46 | click() { 47 | config.set('menuBarMode', false); 48 | toggleMenuBarMode(win); 49 | }, 50 | }, 51 | { 52 | label: 'Show Dock Icon', 53 | type: 'checkbox', 54 | checked: config.get('showDockIcon'), 55 | click(menuItem) { 56 | config.set('showDockIcon', menuItem.checked); 57 | 58 | if (menuItem.checked) { 59 | app.dock.show(); 60 | } else { 61 | app.dock.hide(); 62 | } 63 | 64 | const dockMenuItem = contextMenu.getMenuItemById('dockMenu')!; 65 | dockMenuItem.visible = !menuItem.checked; 66 | }, 67 | }, 68 | { 69 | type: 'separator', 70 | }, 71 | { 72 | id: 'dockMenu', 73 | label: 'Menu', 74 | visible: !config.get('showDockIcon'), 75 | submenu: Menu.getApplicationMenu()!, 76 | }, 77 | ] : []; 78 | 79 | contextMenu = Menu.buildFromTemplate([ 80 | { 81 | label: 'Toggle', 82 | visible: !is.macos, 83 | click() { 84 | toggleWindow(); 85 | }, 86 | }, 87 | ...macosMenuItems, 88 | { 89 | type: 'separator', 90 | }, 91 | { 92 | role: 'quit', 93 | }, 94 | ]); 95 | 96 | tray = new Tray(getIconPath(false)); 97 | 98 | tray.setContextMenu(contextMenu); 99 | 100 | updateToolTip(0); 101 | 102 | const trayClickHandler = (): void => { 103 | if (!win.isFullScreen()) { 104 | toggleWindow(); 105 | } 106 | }; 107 | 108 | tray.on('click', trayClickHandler); 109 | tray.on('double-click', trayClickHandler); 110 | tray.on('right-click', () => { 111 | tray?.popUpContextMenu(contextMenu); 112 | }); 113 | }, 114 | 115 | destroy() { 116 | // Workaround for https://github.com/electron/electron/issues/14036 117 | setTimeout(() => { 118 | tray?.destroy(); 119 | tray = undefined; 120 | }, 500); 121 | }, 122 | 123 | update(messageCount: number) { 124 | if (!tray || previousMessageCount === messageCount) { 125 | return; 126 | } 127 | 128 | previousMessageCount = messageCount; 129 | tray.setImage(getIconPath(messageCount > 0)); 130 | updateToolTip(messageCount); 131 | }, 132 | 133 | setBadge(shouldDisplayUnread: boolean) { 134 | if (is.macos || !tray) { 135 | return; 136 | } 137 | 138 | tray.setImage(getIconPath(shouldDisplayUnread)); 139 | }, 140 | }; 141 | 142 | function updateToolTip(counter: number): void { 143 | if (!tray) { 144 | return; 145 | } 146 | 147 | let tooltip = app.name; 148 | 149 | if (counter > 0) { 150 | tooltip += `- ${counter} unread ${counter === 1 ? 'message' : 'messages'}`; 151 | } 152 | 153 | tray.setToolTip(tooltip); 154 | } 155 | 156 | function getIconPath(hasUnreadMessages: boolean): string { 157 | const icon = is.macos 158 | ? getMacOSIconName(hasUnreadMessages) 159 | : getNonMacOSIconName(hasUnreadMessages); 160 | 161 | return path.join(__dirname, '..', `static/${icon}`); 162 | } 163 | 164 | function getNonMacOSIconName(hasUnreadMessages: boolean): string { 165 | return hasUnreadMessages ? 'IconTrayUnread.png' : 'IconTray.png'; 166 | } 167 | 168 | function getMacOSIconName(hasUnreadMessages: boolean): string { 169 | return hasUnreadMessages ? 'IconMenuBarUnreadTemplate.png' : 'IconMenuBarTemplate.png'; 170 | } 171 | -------------------------------------------------------------------------------- /source/types.ts: -------------------------------------------------------------------------------- 1 | export type IToggleSounds = { 2 | checked: boolean; 3 | }; 4 | 5 | export type IToggleMuteNotifications = { 6 | defaultStatus: boolean; 7 | }; 8 | -------------------------------------------------------------------------------- /source/util.ts: -------------------------------------------------------------------------------- 1 | import { 2 | app, 3 | BrowserWindow, 4 | dialog, 5 | Menu, 6 | } from 'electron'; 7 | import {ipcMain} from 'electron-better-ipc'; 8 | import {is} from 'electron-util'; 9 | import config from './config'; 10 | import tray from './tray'; 11 | 12 | export function getWindow(): BrowserWindow { 13 | const [win] = BrowserWindow.getAllWindows(); 14 | return win; 15 | } 16 | 17 | export function sendAction(action: string, arguments_?: T): void { 18 | const win = getWindow(); 19 | 20 | if (is.macos) { 21 | win.restore(); 22 | } 23 | 24 | ipcMain.callRenderer(win, action, arguments_); 25 | } 26 | 27 | export async function sendBackgroundAction(action: string, arguments_?: T): Promise { 28 | return ipcMain.callRenderer(getWindow(), action, arguments_); 29 | } 30 | 31 | export function showRestartDialog(message: string): void { 32 | const buttonIndex = dialog.showMessageBoxSync( 33 | getWindow(), 34 | { 35 | message, 36 | detail: 'Do you want to restart the app now?', 37 | buttons: [ 38 | 'Restart', 39 | 'Ignore', 40 | ], 41 | defaultId: 0, 42 | cancelId: 1, 43 | }, 44 | ); 45 | 46 | if (buttonIndex === 0) { 47 | app.relaunch(); 48 | app.quit(); 49 | } 50 | } 51 | 52 | export const messengerDomain = config.get('useWorkChat') ? 'facebook.com' : 'messenger.com'; 53 | 54 | export function stripTrackingFromUrl(url: string): string { 55 | const trackingUrlPrefix = `https://l.${messengerDomain}/l.php`; 56 | if (url.startsWith(trackingUrlPrefix)) { 57 | url = new URL(url).searchParams.get('u')!; 58 | } 59 | 60 | return url; 61 | } 62 | 63 | export const toggleTrayIcon = (): void => { 64 | const showTrayIconState = config.get('showTrayIcon'); 65 | config.set('showTrayIcon', !showTrayIconState); 66 | 67 | if (showTrayIconState) { 68 | tray.destroy(); 69 | } else { 70 | tray.create(getWindow()); 71 | } 72 | }; 73 | 74 | export const toggleLaunchMinimized = (menu: Menu): void => { 75 | config.set('launchMinimized', !config.get('launchMinimized')); 76 | const showTrayIconItem = menu.getMenuItemById('showTrayIcon')!; 77 | 78 | if (config.get('launchMinimized')) { 79 | if (!config.get('showTrayIcon')) { 80 | toggleTrayIcon(); 81 | } 82 | 83 | disableMenuItem(showTrayIconItem, true); 84 | 85 | dialog.showMessageBox({ 86 | type: 'info', 87 | message: 'The “Show Tray Icon” setting is force-enabled while the “Launch Minimized” setting is enabled.', 88 | buttons: ['OK'], 89 | }); 90 | } else { 91 | showTrayIconItem.enabled = true; 92 | } 93 | }; 94 | 95 | const disableMenuItem = (menuItem: Electron.MenuItem, checked: boolean): void => { 96 | menuItem.enabled = false; 97 | menuItem.checked = checked; 98 | }; 99 | -------------------------------------------------------------------------------- /static/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/static/Icon.png -------------------------------------------------------------------------------- /static/IconMenuBarTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/static/IconMenuBarTemplate.png -------------------------------------------------------------------------------- /static/IconMenuBarTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/static/IconMenuBarTemplate@2x.png -------------------------------------------------------------------------------- /static/IconMenuBarUnreadTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/static/IconMenuBarUnreadTemplate.png -------------------------------------------------------------------------------- /static/IconMenuBarUnreadTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/static/IconMenuBarUnreadTemplate@2x.png -------------------------------------------------------------------------------- /static/IconTray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/static/IconTray.png -------------------------------------------------------------------------------- /static/IconTray@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/static/IconTray@2x.png -------------------------------------------------------------------------------- /static/IconTrayUnread.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/static/IconTrayUnread.png -------------------------------------------------------------------------------- /static/IconTrayUnread@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/static/IconTrayUnread@2x.png -------------------------------------------------------------------------------- /static/emoji-facebook-2-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/static/emoji-facebook-2-2.png -------------------------------------------------------------------------------- /static/emoji-facebook-2-2@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/static/emoji-facebook-2-2@2x.png -------------------------------------------------------------------------------- /static/emoji-facebook-3-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/static/emoji-facebook-3-0.png -------------------------------------------------------------------------------- /static/emoji-facebook-3-0@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/static/emoji-facebook-3-0@2x.png -------------------------------------------------------------------------------- /static/emoji-messenger-1-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/static/emoji-messenger-1-0.png -------------------------------------------------------------------------------- /static/emoji-messenger-1-0@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/caprine/9a2d7e0d75a6251a84c99cad19820e2d781cf15b/static/emoji-messenger-1-0@2x.png -------------------------------------------------------------------------------- /static/readme.md: -------------------------------------------------------------------------------- 1 | # Notes 2 | 3 | ## Convert `IconTrayUnread` to black & white 4 | 5 | In Photoshop, click `Image → Adjustments → Black & White`, choose the `Default` preset, and then change `Blues` to `-10%`. 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sindresorhus/tsconfig", 3 | "compilerOptions": { 4 | "outDir": "dist-js", 5 | "target": "ES2022", 6 | "module": "commonjs", 7 | "esModuleInterop": true, 8 | "lib": [ 9 | "esnext", 10 | "dom", 11 | "dom.iterable" 12 | ] 13 | } 14 | } --------------------------------------------------------------------------------