Full Code of sindresorhus/caprine for AI

main c3cbcf94e0c2 cached
50 files
170.7 KB
53.7k tokens
113 symbols
1 requests
Download .txt
Repository: sindresorhus/caprine
Branch: main
Commit: c3cbcf94e0c2
Files: 50
Total size: 170.7 KB

Directory structure:
gitextract_9iro8akf/

├── .editorconfig
├── .gitattributes
├── .github/
│   ├── dependabot.yml
│   └── workflows/
│       ├── build.yml
│       └── tests.yml
├── .gitignore
├── .nvmrc
├── .prettierignore
├── build/
│   ├── entitlements.mac.plist
│   └── icon.icns
├── css/
│   ├── autoplay.css
│   ├── browser.css
│   ├── code-blocks.css
│   ├── dark-mode.css
│   ├── scrollbar.css
│   ├── vibrancy.css
│   └── workchat.css
├── license
├── media/
│   └── AppIcon.sketch
├── 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/
│   │   ├── conversation-list.ts
│   │   └── selectors.ts
│   ├── browser-call.ts
│   ├── browser.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/
│   └── readme.md
└── tsconfig.json

================================================
FILE CONTENTS
================================================

================================================
FILE: .editorconfig
================================================
root = true

[*]
indent_style = tab
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.yml]
indent_style = space
indent_size = 2


================================================
FILE: .gitattributes
================================================
* text=auto eol=lf


================================================
FILE: .github/dependabot.yml
================================================
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for more information:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
# https://containers.dev/guide/dependabot

version: 2
updates:
 - package-ecosystem: "devcontainers"
   directory: "/"
   schedule:
     interval: weekly


================================================
FILE: .github/workflows/build.yml
================================================
name: Build and publish
on:
  push:
    tags:
      - '*'
jobs:
  tests:
    uses: ./.github/workflows/tests.yml
  build:
    needs: [tests]
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os:
          - macos-latest
          - ubuntu-latest
          - windows-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - name: Install dependencies
        run: npm ci
      - name: Build Caprine
        run: npm run build
      - name: Cleanup tag
        uses: mad9000/actions-find-and-replace-string@5
        id: release_tag
        with:
          source: ${{ github.ref_name }}
          find: v
          replace: ''
      - name: Install Snapcraft
        uses: samuelmeuli/action-snapcraft@v1
        if: startsWith(matrix.os, 'ubuntu')
      - name: Package Caprine for macOS
        if: startsWith(matrix.os, 'macos')
        run: npm run dist:mac
        env:
          CSC_LINK: ${{ secrets.CSC_LINK }}
          CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - name: Package Caprine for Windows
        if: startsWith(matrix.os, 'windows')
        run: npm run dist:win
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - name: Package Caprine for Linux
        if: startsWith(matrix.os, 'ubuntu')
        run: npm run dist:linux
        env:
          SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }}
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - name: Upload to Gemfury
        if: startsWith(matrix.os, 'ubuntu')
        run: curl -F package=@dist/caprine_${{ steps.release_tag.outputs.value }}_amd64.deb https://${{ secrets.gemfury_token }}@push.fury.io/lefterisgar/


================================================
FILE: .github/workflows/tests.yml
================================================
name: Tests

on:
  push:
    branches-ignore:
      - gh-pages
  pull_request:
  workflow_call:

jobs:
  npm-cache:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - name: Install dependencies
        run: npm ci
  tsc:
    runs-on: ubuntu-latest
    needs: npm-cache
    steps:
      - uses: actions/checkout@v4
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - name: Compile TypeScript
        run: |
          npm ci
          npm run test:tsc
  xo:
    runs-on: ubuntu-latest
    needs: npm-cache
    steps:
      - uses: actions/checkout@v4
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - name: Lint source code
        run: |
          npm ci
          npm run lint:xo
  stylelint:
    runs-on: ubuntu-latest
    needs: npm-cache
    steps:
      - uses: actions/checkout@v4
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - name: Lint styles
        run: |
          npm ci
          npm run lint:stylelint
  rpmspec:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Lint rpm spec file
        uses: EyeCantCU/rpmlint-action@v0.1.1
        with:
          rpmfiles: packages/rpm/caprine.spec


================================================
FILE: .gitignore
================================================
node_modules
yarn.lock
/dist
/dist-js
mas.provisionprofile
.vscode
.DS_store
/.devcontainer


================================================
FILE: .nvmrc
================================================
lts/hydrogen


================================================
FILE: .prettierignore
================================================
*.md


================================================
FILE: build/entitlements.mac.plist
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>com.apple.security.cs.allow-jit</key>
	<true/>
	<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
	<true/>
	<key>com.apple.security.cs.allow-dyld-environment-variables</key>
	<true/>
	<key>com.apple.security.device.camera</key>
	<true/>
	<key>com.apple.security.device.audio-input</key>
	<true/>
</dict>
</plist>


================================================
FILE: css/autoplay.css
================================================
.disabledAutoPlayImgTopRadius {
	border-top-left-radius: 1.3em;
	border-top-right-radius: 1.3em;
}

.disabledAutoPlayImgBottomRadius {
	border-bottom-left-radius: 1.3em;
	border-bottom-right-radius: 1.3em;
}


================================================
FILE: css/browser.css
================================================
:root {
	--selected-conversation-background: linear-gradient(hsla(209deg 110% 45% / 90%), hsla(209deg 110% 42% / 90%));
	--selected-conversation-background-inactive: #d2d2d2;
	--black: #000;
}

html {
	overflow: hidden;
}

/* Add OS-specific fonts */
body {
	font-family:
		-apple-system,
		BlinkMacSystemFont,
		'Segoe UI',
		Roboto,
		Oxygen-Sans,
		Ubuntu,
		Cantarell,
		'Helvetica Neue',
		sans-serif,
		'Apple Color Emoji',
		'Segoe UI Emoji',
		'Segoe UI Symbol' !important;
	text-rendering: optimizelegibility !important;
	font-feature-settings: 'liga', 'clig', 'kern';
}

/* Bind the toolbar as the window's draggable region */
/* Bar above conversation list */
[role='navigation'] .x78zum5.xdt5ytf.xzd29fr {
	-webkit-app-region: drag;
}
/* New message button */
[role='navigation'] .x16n37ib {
	-webkit-app-region: no-drag;
}
/* Bar above chat */
[role='main'] .x1u998qt.x1vjfegm {
	-webkit-app-region: drag;
}
/* Conversation name */
[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 {
	-webkit-app-region: no-drag;
}
/* Top right buttons */
[role='main'] .x9f619.x1n2onr6.x1ja2u2z.x78zum5.x2lah0s.x1qughib.x6s0dn4.xozqiw3.x1q0g3np.xykv574.xbmpl8g.x4cne27.xifccgj {
	-webkit-app-region: no-drag;
}
/* View label above conversation list margin */
.os-darwin [role='navigation'] .x1heor9g.x1qlqyl8.x1pd3egz.x1a2a7pz {
	margin-left: 60px;
}

/* Hide footer at login view */
._210n {
	display: none;
}

/* Don't show outline on clickable elements & input fields */
*[role='button'],
*[type='text'],
*[type='password'] {
	outline: none !important;
}

[role='navigation'] a {
	cursor: default !important;
}

/* Remove top Facebook cookie banner */
.fbPageBanner {
	display: none !important;
}

/* Cookies notification: Adjust size for smaller windows */
._9o-g {
	height: 290px !important;
}
._9xo5 {
	padding-top: 12px !important;
}
._59s7._9l2g {
	height: 498px !important;
}

/* Cookies notification: Remove "allow all cookies" button */
._42ft._4jy0._9xo7._4jy3._4jy1.selected._51sy {
	display: none;
}

/* Cookies notification: accept button */
._42ft._4jy0._9xo6._4jy3._4jy1.selected._51sy {
	background-color: #1877f2 !important;
	color: var(--white) !important;
}

/* Hide disabled scrollbar on the right side */
body::-webkit-scrollbar {
	display: none;
}

/* A utility class for temporarily hiding all dropdown menus */
html.hide-dropdowns [role='menu'].x1n2onr6.xi5betq {
	visibility: hidden !important;
}

/* A utility class for temporarily hiding preferences window */
html.hide-preferences-window div[class='x9f619 x1n2onr6 x1ja2u2z'] > div:nth-of-type(3) > div > div {
	display: none;
}

/* -- Private mode -- */
/* Preferences button: profile picture */
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 {
	filter: blur(5px);
}
/* Preferences: profile picture */
html.private-mode .alzwoclg.b0eko5f3.q46jt4gp.r5g9zsuq .aglvbi8b.om3e55n1.i8zpp7h3.g4tp4svg {
	filter: blur(5px);
}
/* Preferences: account name */
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 {
	filter: blur(5px);
}
/* Chat list: name, profile picture and last message */
html.private-mode [role='navigation'] [role='row'] .b6ax4al1.gvxzyvdx {
	filter: blur(5px);
}
/* Chat list: person tiny heads */
html.private-mode [role='row'] .aglvbi8b.om3e55n1.i8zpp7h3.g4tp4svg {
	filter: blur(3px);
}
/* Conversation: titlebar profile picture */
html.private-mode .b6ax4al1.gvxzyvdx.dgxim35p.p9wrh9lq {
	filter: blur(5px);
}
/* Conversation: sender profile picture */
html.private-mode .mfclru0v.p9wrh9lq.pytsy3co.aglvbi8b {
	filter: blur(5px);
}
/* Conversation: name & last seen */
html.private-mode [role='main'] .alzwoclg.cqf1kptm.hael596l.jcxyg2ei.cgu29s5g.dn6jqzda {
	filter: blur(5px);
}
/* Conversation: read indicator */
html.private-mode [role='main'] .iec8yc8l.b7mnygb8.dktd5soj.f14ij5to.qmqpeqxj.e7u6y3za.qwcclf47.nmlomj2f {
	filter: blur(5px);
}
/* Conversation: name & details at the beginning */
html.private-mode .h6ft4zvz.rj2hsocd.aesu6q9g.e4ay1f3w .hsphh064.pk1vzqw1.hxfwr5lz.qc5lal2y {
	filter: blur(5px);
}
/* Right sidebar: profile picture */
html.private-mode .aglvbi8b.om3e55n1.i8zpp7h3.g4tp4svg {
	filter: blur(5px);
}
/* Right sidebar: name */
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 {
	filter: blur(5px);
}
/* Right sidebar: active status */
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 {
	filter: blur(5px);
}
/* New conversation: profile picture */
html.private-mode .mfclru0v.pytsy3co.qmqpeqxj.e7u6y3za.qwcclf47.nmlomj2f.i8zpp7h3.p9wrh9lq {
	filter: blur(5px);
}
/* New conversation: profile picture (groups) */
html.private-mode .qmqpeqxj.e7u6y3za.qwcclf47.nmlomj2f.s8sjc6am {
	filter: blur(5px);
}
/* Calls: incoming call dialog account name */
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,
html.private-mode .gvxzyvdx.aeinzg81.t7p7dqev.gh25dzvf.rse6dlih.ocv3nf92.nfkogyam.innypi6y.rtxb060y.qc5lal2y {
	filter: blur(10px);
}

/* Force max-width on videos */
.ni8dbmo4.stjgntxs.g5ia77u1.ii04i59q.j83agx80.cbu4d94t.ll8tlv6m > span,
.l9j0dhe7.km676qkl.cxmmr5t8.myj7ivm5.hcukyx3x,
.opwvks06.hop1g133.linmgsc8.t63ysoy8.qutah8gn.ni8dbmo4.stjgntxs.ktxn16wu.jz9ahs1c.efwgsih4.e72ty7fz.qmr60zad.qlfml3jp.inkptoze {
	max-width: 100%;
}

/* Hide the "Messenger App for Mac/Windows" banner in chat list */
.x9f619.x1n2onr6.x1ja2u2z.x78zum5.x1r8uery.xs83m0k.xeuugli.x1qughib.x6s0dn4.xozqiw3.x1q0g3np.xknmibj.x1c4vz4f.xt55aet.xexx8yu.xc73u3c.x18d9i69.x5ib6vp.x1lku1pv.xzd29fr {
	display: none;
}
/* Hide the "Messenger for Mac/Windows" menu item and separator in Messenger settings */
.x4k7w5x.x1h91t0o.x1beo9mf.xaigb6o.x12ejxvf.x3igimt.xarpa2k.xedcshv.x1lytzrv.x1t2pt76.x7ja8zs.x1n2onr6.x1qrby5j.x1jfb8zj > div > div:last-of-type > a {
	display: none;
}

/* Dragable region for macOS */
.os-darwin .rq0escxv.l9j0dhe7.du4w35lb.j83agx80.pfnyh3mw.i1fnvgqd.bp9cbjyn.owycx6da.btwxx1t3.jei6r52m.wkznzc2l.n851cfcs.dhix69tm.ahb00how,
.os-darwin .rq0escxv.l9j0dhe7.du4w35lb.j83agx80.pfnyh3mw.i1fnvgqd.bp9cbjyn.owycx6da.btwxx1t3.hv4rvrfc.dati1w0a.f10w8fjw.pybr56ya.b5q2rw42.lq239pai.mysgfdmx.hddg9phg {
	-webkit-app-region: drag !important;
}
.os-darwin .rq0escxv.l9j0dhe7.du4w35lb.j83agx80.pfnyh3mw.i1fnvgqd.bp9cbjyn.owycx6da.btwxx1t3.jei6r52m.wkznzc2l.n851cfcs.dhix69tm.ahb00how {
	margin: 0 !important;
	padding: 12px 16px 0 !important;
}
.os-darwin .rq0escxv.l9j0dhe7.du4w35lb.j83agx80.cbu4d94t.g5gj957u.d2edcug0.hpfvmrgz.kud993qy.buofh1pr {
	margin-top: 24px !important;
}

@media (max-width: 900px) {
	.os-darwin .rq0escxv.l9j0dhe7.du4w35lb.jbae33se.hv4rvrfc.dati1w0a.pybr56ya {
		display: none !important;
	}
	.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 {
		padding-top: 36px !important;
	}
}

/* -- Sidebar views -- */

/* Hidden: Hide sidebar */
html.sidebar-hidden .bdao358l.om3e55n1.alzwoclg.cqf1kptm.gvxzyvdx.aeinzg81.jez8cy9q.fawcizw8.sl4bvocy.mm98tyaj.b0ur3jhr.f76nr8pf {
	display: none;
}

/* Narrow: Hide preferences button */
html.sidebar-force-narrow .pvreidsc.r227ecj6.n68fow1o.gt60zsk1.lth9pzmp {
	display: none;
}
/* Narrow: Hide conversation previews */
html.sidebar-force-narrow .bdao358l.om3e55n1.g4tp4svg.alzwoclg.cqf1kptm.jez8cy9q.gvxzyvdx.aeinzg81.cgu29s5g {
	display: none;
}
/* Narrow: Hide search bar */
html.sidebar-force-narrow .bdao358l.om3e55n1.g4tp4svg.r227ecj6.gt60zsk1.rj2hsocd.f9xcifuu {
	display: none;
}
/* Narrow: Width of conversation list */
html.sidebar-force-narrow .bdao358l.om3e55n1.alzwoclg.cqf1kptm.gvxzyvdx.aeinzg81.jez8cy9q.fawcizw8.sl4bvocy.mm98tyaj.b0ur3jhr.f76nr8pf {
	width: 80px;
}

/* -- Toggle message buttons -- */
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,
body .s8sjc6am.z6erz7xo.alzwoclg.jcxyg2ei.i85zmo3j.b0ur3jhr.kjdc1dyq.tjkoo78o.iwr3bmeu.qgj99rie {
	display: flex;
}

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,
body.show-message-buttons .s8sjc6am.z6erz7xo.alzwoclg.jcxyg2ei.i85zmo3j.b0ur3jhr.kjdc1dyq.tjkoo78o.iwr3bmeu.qgj99rie {
	display: none;
}

body.show-message-buttons .cgu29s5g.alzwoclg.oo5upp5e {
	margin-left: 15px !important;
}


================================================
FILE: css/code-blocks.css
================================================
/* Tomorrow light theme for code blocks */
._wu0 {
	--code-block-base: #1d1f21;
	--code-block-background: transparent;
	--code-block-border: rgb(0 0 0 / 10%);
	--code-block-primary: #de935f;
	--code-block-meta: #969896;
	--code-block-tag: #a3685a;
	--code-block-quoted: #b5bd68;
	--code-block-variable: #c66;
	--code-block-special: #8abeb7;
	--code-block-attr-value: #139543;
	--code-block-keyword: #b294bb;
	--code-block-function: #81a2be;
	background-color: var(--code-block-background) !important;
	border: 1px solid var(--code-block-border) !important;
	color: var(--code-block-base) !important;
}
._wu0 .token.punctuation {
	color: var(--code-block-base) !important;
}
._wu0 .token.property {
	color: var(--code-block-base) !important;
}
._wu0 .token.operator {
	color: var(--code-block-base) !important;
}
._wu0 .token.boolean {
	color: var(--code-block-primary) !important;
}
._wu0 .token.number {
	color: var(--code-block-primary) !important;
}
._wu0 .token.constant {
	color: var(--code-block-primary) !important;
}
._wu0 .token.selector {
	color: var(--code-block-primary) !important;
}
._wu0 .token.bold {
	color: var(--code-block-primary) !important;
	font-weight: bold;
}
._wu0 .token.comment {
	color: var(--code-block-meta) !important;
}
._wu0 .token.prolog {
	color: var(--code-block-meta) !important;
}
._wu0 .token.doctype {
	color: var(--code-block-meta) !important;
}
._wu0 .token.cdata {
	color: var(--code-block-meta) !important;
}
._wu0 .token.tag {
	color: var(--code-block-tag) !important;
}
._wu0 .token.symbol {
	color: var(--code-block-quoted) !important;
}
._wu0 .token.string {
	color: var(--code-block-quoted) !important;
}
._wu0 .token.char {
	color: var(--code-block-quoted) !important;
}
._wu0 .token.inserted {
	color: var(--code-block-quoted) !important;
}
._wu0 .token.attr-name {
	color: var(--code-block-variable) !important;
}
._wu0 .token.url {
	color: var(--code-block-variable) !important;
}
._wu0 .token.entity {
	color: var(--code-block-variable) !important;
}
._wu0 .token.variable {
	color: var(--code-block-variable) !important;
}
._wu0 .token.deleted {
	color: var(--code-block-variable) !important;
}
._wu0 .token.builtin {
	color: var(--code-block-special) !important;
}
._wu0 .token.hexcode {
	color: var(--code-block-special) !important;
}
._wu0 .token.regex {
	color: var(--code-block-special) !important;
}
._wu0 .token.attr-value {
	color: var(--code-block-attr-value) !important;
}
._wu0 .token.keyword {
	color: var(--code-block-keyword) !important;
}
._wu0 .token.important {
	color: var(--code-block-keyword) !important;
}
._wu0 .token.italic {
	color: var(--code-block-keyword) !important;
	font-style: italic;
}
._wu0 .token.function {
	color: var(--code-block-function) !important;
}

/* Tomorrow dark theme for code blocks */
html.dark-mode ._wu0 {
	--code-block-base: #c5c8c6;
	--code-block-border: var(--base-ten);
	color: var(--base);
}

/* Full-window vibrancy */
html.full-vibrancy ._wu0 {
	--code-block-background: #fff;
	--code-block-border: transparent;
}
html.full-vibrancy.dark-mode ._wu0 {
	--code-block-background: var(--container-color);
}


================================================
FILE: css/dark-mode.css
================================================
:root {
	--base: #000;
	--base-ninety: rgb(255 255 255 / 90%);
	--base-seventy-five: rgb(255 255 255 / 75%);
	--base-seventy: rgb(255 255 255 / 70%);
	--base-fifty: rgb(255 255 255 / 50%);
	--base-fourty: rgb(255 255 255 / 40%);
	--base-thirty: rgb(255 255 255 / 30%);
	--base-twenty: rgb(255 255 255 / 20%);
	--base-ten: rgb(255 255 255 / 10%);
	--base-nine: rgb(255 255 255 / 9%);
	--base-five: rgb(255 255 255 / 5%);
	--container-color: #323232;
	--container-dark-color: #1e1e1e;
	--list-header-color: #222;
	--blue: #0084ff;
	--white: #fff;
}

html.dark-mode body {
	color: var(--base-seventy);
	background: var(--container-color) !important;
}

/* Fixes appearance of "Verify Account" screen text */
html.dark-mode ._3-mr ._3-mt,
html.dark-mode ._3-mr ._3-mu {
	color: #fff;
}

html.dark-mode ._3v_o, /* Login screen */
html.dark-mode body.UIPage_LoggedOut ._li, /* 2FA screen */
html.dark-mode body.UIPage_LoggedOut ._4-u5 /* 2FA screen */ {
	background-color: var(--container-dark-color);
}

/* Login title and names */
html.dark-mode ._5hy4,
html.dark-mode ._3403 {
	color: var(--base-fourty) !important;
}

/* Login inputs */
html.dark-mode ._3v_o ._55r1 {
	background: var(--base-five);
	color: var(--base-seventy);
}
html.dark-mode ._3v_o ._55r1::-webkit-input-placeholder {
	color: var(--base-thirty) !important;
}

/* "Keep me signed in" checkbox */
html.dark-mode .uiInputLabelInput {
	filter: opacity(70%);
}

/* "Keep me signed in" text */
html.dark-mode .uiInputLabelLabel {
	color: var(--base-fourty) !important;
}

/* 2FA screen modal */
html.dark-mode body.UIPage_LoggedOut ._4-u8 {
	background: var(--container-color);
	border-color: var(--base-five) !important;
}

/* 2FA screen modal title */
html.dark-mode body.UIPage_LoggedOut ._2e9n {
	border-color: var(--base-five);
	color: #fff;
}

/* 2FA screen modal separator */
html.dark-mode body.UIPage_LoggedOut ._p0k ._5hzs {
	border-color: var(--base-five);
}

/* 2FA screen modal separators */
html.dark-mode body.UIPage_LoggedOut a {
	color: var(--blue);
}

/* 2FA screen modal input */
html.dark-mode body.UIPage_LoggedOut input {
	background: var(--base-ten);
	border-color: var(--base-ten);
	color: var(--base-ninety);
}

/* Cookies notification: background */
html.dark-mode ._9o-w ._9o-c {
	background: var(--container-color) !important;
}
/* Cookies notification: text */
html.dark-mode ._9o-g {
	color: var(--base-seventy) !important;
}
/* Cookies notification: collapsible headers */
html.dark-mode ._9o-l {
	color: var(--base-seventy) !important;
}
/* Cookies notification: subheaders */
html.dark-mode ._9si- {
	color: var(--base-seventy) !important;
}
/* Cookies notification: hamburger menu */
html.dark-mode ._42ft._4jy0._55pi._2agf._4o_4._9o-e._p._4jy3._517h._51sy {
	background: var(--container-color) !important;
}
/* Cookies notification: hamburger menu background */
html.dark-mode ._54ng {
	background: var(--container-color) !important;
}
/* Cookies notification: hamburger menu text */
html.dark-mode ._54nh {
	color: var(--base-seventy) !important;
}
/* Cookies notification: hamburger menu column borders */
html.dark-mode ._54nc {
	border-color: var(--container-color) !important;
}
/* Cookies notification: icons */
html.dark-mode .img.sp_ng1YXMZLXub {
	filter: invert(0.66);
}
/* Cookies notification: rectangular boxes */
html.dark-mode .pam._9o-n.uiBoxGray {
	background-color: var(--base-ten) !important;
}
html.dark-mode ._9xq0 {
	color: var(--base-seventy) !important;
}

/* Top bar: App menu button color */
/* Top bar: New message button color */
.j83agx80.pfnyh3mw .ozuftl9m .a8c37x1j.ms05siws.hwsy1cff.b7h9ocf4 {
	fill: currentcolor;
	color: var(--primary-text);
}

/* Chat list: Mute icon */
.bp9cbjyn.j83agx80.btwxx1t3 .dlv3wnog.lupvgy83 .a8c37x1j {
	fill: currentcolor;
	color: var(--primary-text);
}

/* Right sidebar: icons */
.x1qhmfi1.x14yjl9h.xudhj91.x18nykt9.xww2gxu.x1fgtraw.x1264ykn.x78zum5.x6s0dn4.xl56j7k svg path {
	fill: currentcolor;
	color: var(--primary-text);
}

/* Contact list: delivered icon color */
.aahdfvyu [role='grid'] .a8c37x1j.ms05siws.hwsy1cff.b7h9ocf4 {
	fill: currentcolor;
	color: var(--primary-text);
}

/* Messenger settings: Privacy & safety icon color */
.x1lliihq.x1k90msu.x2h7rmj.x1qfuztq.x198g3q0.xxk0z11.xvy4d1p {
	fill: currentcolor;
	color: var(--primary-text);
}

/* Radio buttons */
.x14yjl9h.xudhj91.x18nykt9.xww2gxu.x13fuv20.xu3j5b3.x1q0q8m5.x26u7qi.xamhcws.xol2nv.xlxy82.x19p7ews.x9f619.x1rg5ohu.x2lah0s.x1n2onr6.x1tz4bnf.xmds5ef.x25epmt.x11y6y4w.xxk0z11.xvy4d1p {
	--accent: var(--primary-text);
}

/* Create room icon color */
html.dark-mode .x1p6odiv {
	color: var(--primary-icon);
}


================================================
FILE: css/scrollbar.css
================================================
/* Custom native scrollbar */

/* Light theme */
::-webkit-scrollbar {
	width: 16px;
}
::-webkit-scrollbar-thumb {
	box-shadow: inset 0 0 16px 16px #bcc0c4;
	border-radius: 8px;
	border: solid 4px transparent;
}
::-webkit-scrollbar-track {
	box-shadow: inset 0 0 16px 16px #fff;
}
::-webkit-scrollbar-track:hover {
	box-shadow: inset 0 0 16px 16px #f0f1f2;
}

/* Dark theme */
html.dark-mode ::-webkit-scrollbar {
	width: 16px;
}
html.dark-mode ::-webkit-scrollbar-thumb {
	box-shadow: inset 0 0 16px 16px #ffffff4c;
	border-radius: 8px;
	border: solid 4px transparent;
}
html.dark-mode ::-webkit-scrollbar-track {
	box-shadow: inset 0 0 16px 16px #0000;
}
html.dark-mode ::-webkit-scrollbar-track:hover {
	box-shadow: inset 0 0 16px 16px #ffffff10;
}


================================================
FILE: css/vibrancy.css
================================================
/* -- BLOCK START: sidebar-only vibrancy -- */

html.sidebar-vibrancy body,
html.sidebar-vibrancy ._4sp8,
html.sidebar-vibrancy.dark-mode body,
html.sidebar-vibrancy.dark-mode ._4sp8 {
	background: transparent !important;
}

/* Login screen */
html.sidebar-vibrancy ._3v_o {
	background-color: #fff;
}
html.sidebar-vibrancy.dark-mode ._3v_o {
	background-color: var(--container-dark-color);
}

/* Message placeholder text color */
html.sidebar-vibrancy ._kmc ._1p1t {
	color: #999 !important;
	-webkit-text-fill-color: #999 !important;
}

/* Contact list: header above */
html.sidebar-vibrancy.dark-mode ._36ic {
	background: transparent !important;
}

/* Contact list: search input */
html.sidebar-vibrancy ._5iwm ._58al {
	background-color: rgb(246 247 249 / 50%) !important;
}
html.sidebar-vibrancy.dark-mode ._5iwm ._58al {
	background: var(--base-five) !important;
}

/* Chat title bar */
html.sidebar-vibrancy ._673w {
	background-color: #fff !important;
}

html.sidebar-vibrancy.dark-mode ._673w {
	background-color: var(--container-dark-color) !important;
	border-bottom: 1px solid var(--base-five) !important;
}

/* Share previews: title and subtitle */
html.sidebar-vibrancy .__6k,
html.sidebar-vibrancy .__6l {
	background-color: transparent !important;
}

/* Message container + right sidebar */
html.sidebar-vibrancy ._4_j4,
html.sidebar-vibrancy ._4_j5 {
	background: #fff !important;
}
html.sidebar-vibrancy.dark-mode ._4_j4,
html.sidebar-vibrancy.dark-mode ._4_j5 {
	background: var(--container-dark-color) !important;
}

/* Message list: header above */
html.sidebar-vibrancy.dark-mode ._5742 {
	background: transparent !important;
}

/* New conversation name input field */
html.sidebar-vibrancy ._2y8y {
	background: #fff !important;
}
html.sidebar-vibrancy.dark-mode ._2y8y {
	background: var(--container-dark-color) !important;
}

/* Message text bar */
html.sidebar-vibrancy.dark-mode ._5irm._7mkm {
	background: var(--container-dark-color);
}

/* -- BLOCK END: sidebar-only vibrancy -- */

/* -- BLOCK START: full-window vibrancy -- */

html.full-vibrancy body,
html.full-vibrancy ._4sp8 {
	background: transparent !important;
}


html.full-vibrancy ._5hy2 ._43dh, /* Login button */
html.full-vibrancy ._3-mr ._3-mv /* Verification "Continue" button */ {
	background-color: transparent !important;
}

/* Message placeholder text color */
html.full-vibrancy ._kmc ._1p1t {
	color: #999 !important;
	-webkit-text-fill-color: #999 !important;
}

/* Messages list: start conversation with a chat bot */
html.full-vibrancy ._2xh0 ._3zc8 {
	background-color: transparent;
}

/* Messages list: start conversation with a chat bot buttons */
html.full-vibrancy ._2xh0 ._2xh4 {
	background-color: transparent;
}

/* Contact list: search input */
html.full-vibrancy ._5iwm ._58al {
	background-color: rgb(246 247 249 / 50%) !important;
}
html.full-vibrancy.dark-mode ._5iwm ._58al {
	background: var(--base-five) !important;
}

/* Chat title bar */
html.full-vibrancy ._673w,
html.full-vibrancy.dark-mode ._673w {
	background-color: transparent !important;
}

/* Share previews: title and subtitle */
html.full-vibrancy .__6k,
html.full-vibrancy .__6l {
	background-color: transparent !important;
}

/* Contact list: person container */
html.full-vibrancy ._1qt4 {
	border-top: solid 1px rgb(0 0 0 / 6%);
}

/* Main content */
html.full-vibrancy.dark-mode ._1q5- {
	background: transparent !important;
}

/* Message list: header above */
html.full-vibrancy.dark-mode ._5742 {
	background: transparent !important;
}

/* Contact list: header above */
html.full-vibrancy.dark-mode ._36ic {
	background: transparent !important;
}

/* Message container + right sidebar */
html.full-vibrancy ._4_j4,
html.full-vibrancy ._4_j5,
html.full-vibrancy.dark-mode ._4_j4,
html.full-vibrancy.dark-mode ._4_j5 {
	background: transparent !important;
}

/* New conversation name input field */
html.full-vibrancy ._2y8y,
html.full-vibrancy.dark-mode ._2y8y {
	background: transparent !important;
}

/* Message composer buttons */
html.full-vibrancy ._39bj {
	filter: brightness(0.8);
}
html.full-vibrancy.dark-model ._39bj {
	filter: brightness(1);
}

/* Deleted message */
html.full-vibrancy ._7301._hh7 {
	background-color: #fff;
	border-color: transparent !important;
}
html.full-vibrancy.dark-mode ._7301._hh7 {
	background-color: var(--container-color);
}

/* Message list: link preview */
html.full-vibrancy ._5i_d {
	background-color: #fff;
	border-color: transparent !important;
}
html.full-vibrancy.dark-mode ._5i_d {
	background-color: var(--container-color);
}

/* Message composer: attached files */
html.full-vibrancy ._2zl5 {
	background-color: #fff;
	border-color: transparent;
}
html.full-vibrancy.dark-mode ._2zl5 {
	background-color: var(--container-color);
}

/* Reply tag icon */
html.full-vibrancy ._6e38 {
	filter: brightness(0.66);
}

/* Message composer: link preview */
html.full-vibrancy .chatAttachmentShelf,
html.full-vibrancy .fbNubFlyoutAttachments,
html.full-vibrancy.dark-mode .chatAttachmentShelf,
html.full-vibrancy.dark-mode .fbNubFlyoutAttachments {
	background: transparent !important;
}
html.full-vibrancy .chatAttachmentShelf,
html.full-vibrancy.dark-mode .chatAttachmentShelf {
	border-top-color: rgb(0 0 0 / 10%);
}

/* Message text bar */
html.full-vibrancy ._5irm._7mkm {
	background: transparent;
}

/* Additional "plus" bar */
html.full-vibrancy ._7mkk._7t1o._7t0j {
	display: none;
}
/* -- BLOCK END: full-window vibrancy -- */


================================================
FILE: css/workchat.css
================================================
/* Main: Hide header */
#pagelet_bluebar {
	display: none;
}

/* Login: Remove vertical scrollbar */
body {
	overflow: hidden !important;
}

/* Login: Remove white top bar */
._4b21 {
	display: none;
}

/* Login: Remove Facebook links */
#pageFooter {
	display: none;
}

/* Login: Vertically center login box */
html {
	height: 100%;
}
body {
	height: 100%;
}
.UIPage_LoggedOut ._li {
	height: 100%;
}
#globalContainer {
	display: flex;
	flex-direction: column;
	justify-content: center;
	height: 100%;
}
._5rw2 {
	min-height: 0 !important;
	padding: 0 !important;
}

/**
 * Dark Mode
 */

/* Login: Background */
html.dark-mode ._5rw0 {
	color: var(--base-seventy);
	background-color: transparent !important;
}

/* Login: Logo */
html.dark-mode .sx_2c5ee7 {
	filter: brightness(4);
}

/* Login: Slogan */
html.dark-mode ._5zi0 {
	filter: brightness(3);
}

/* Login: Fix background color in vibrancy mode */
html.vibrancy ._5rw0 {
	background-color: transparent !important;
}
html.vibrancy:not(.dark-mode) ._5rw2 {
	color: #242424 !important;
}

/* Login: Login button */
html.dark-mode.vibrancy button {
	background: var(--container-color) !important;
}

/* Login: Remove login button border */
html.dark-mode button {
	border-color: var(--container-color) !important;
}

/* Login: Email confirmation screen */
html.dark-mode ._5rwd {
	color: var(--base-seventy);
	background: var(--container-color) !important;
}

/* Contact List: Search results */
html.dark-mode ._4p-s {
	background: var(--base-ten) !important;
}

html.dark-mode ._aou {
	background: var(--base) !important;
}

html.dark-mode ._4kf5 {
	background: var(--base) !important;
}


================================================
FILE: license
================================================
MIT License

Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)

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:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

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.


================================================
FILE: package.json
================================================
{
	"name": "caprine",
	"productName": "Caprine",
	"version": "2.61.0",
	"description": "Elegant Facebook Messenger desktop app",
	"license": "MIT",
	"repository": "sindresorhus/caprine",
	"author": {
		"name": "Sindre Sorhus",
		"email": "sindresorhus@gmail.com",
		"url": "https://sindresorhus.com"
	},
	"main": "dist-js",
	"engines": {
		"node": ">=16"
	},
	"scripts": {
		"postinstall": "patch-package && electron-builder install-app-deps",
		"lint:xo": "xo",
		"lint:stylelint": "stylelint \"css/**/*.css\"",
		"lint": "npm run lint:xo && npm run lint:stylelint",
		"test:tsc": "npm run build",
		"test": "npm run test:tsc && npm run lint",
		"start": "tsc && electron .",
		"build": "tsc",
		"dist:linux": "electron-builder --linux",
		"dist:mac": "electron-builder --mac",
		"dist:win": "electron-builder --win",
		"release": "np --no-publish"
	},
	"dependencies": {
		"@electron/remote": "^2.1.2",
		"@sindresorhus/do-not-disturb": "^1.1.0",
		"electron-better-ipc": "^2.0.1",
		"electron-context-menu": "^3.6.1",
		"electron-debug": "^3.2.0",
		"electron-dl": "^3.5.2",
		"electron-localshortcut": "^3.2.1",
		"electron-store": "^8.1.0",
		"electron-updater": "^6.1.8",
		"electron-util": "^0.17.2",
		"element-ready": "^5.0.0",
		"facebook-locales": "^1.0.916",
		"is-online": "^9.0.1",
		"json-schema-typed": "^8.0.1",
		"lodash": "^4.17.21",
		"npm-check-updates": "^16.14.15",
		"p-wait-for": "^3.2.0"
	},
	"devDependencies": {
		"@sindresorhus/tsconfig": "^0.7.0",
		"@types/electron-localshortcut": "^3.1.3",
		"@types/facebook-locales": "^1.0.2",
		"@types/lodash": "^4.14.202",
		"del-cli": "^5.1.0",
		"electron": "^29.0.1",
		"electron-builder": "^24.12.0",
		"husky": "^9.0.11",
		"np": "^9.2.0",
		"patch-package": "^8.0.0",
		"stylelint": "^14.10.0",
		"stylelint-config-xo": "^0.22.0",
		"typescript": "^5.3.3",
		"xo": "^0.57.0"
	},
	"xo": {
		"envs": [
			"node",
			"browser"
		],
		"rules": {
			"@typescript-eslint/ban-ts-comment": "off",
			"@typescript-eslint/consistent-type-imports": "off",
			"@typescript-eslint/naming-convention": "off",
			"@typescript-eslint/no-floating-promises": "off",
			"@typescript-eslint/no-loop-func": "off",
			"@typescript-eslint/no-non-null-assertion": "off",
			"@typescript-eslint/no-require-imports": "off",
			"@typescript-eslint/no-unsafe-argument": "off",
			"@typescript-eslint/no-unsafe-assignment": "off",
			"@typescript-eslint/no-unsafe-call": "off",
			"@typescript-eslint/no-unsafe-enum-comparison": "off",
			"@typescript-eslint/no-var-requires": "off",
			"import/extensions": "off",
			"import/no-anonymous-default-export": "off",
			"import/no-cycle": "off",
			"n/file-extension-in-import": "off",
			"unicorn/prefer-at": "off",
			"unicorn/prefer-module": "off",
			"unicorn/prefer-top-level-await": "off"
		}
	},
	"stylelint": {
		"extends": "stylelint-config-xo",
		"rules": {
			"declaration-no-important": null,
			"no-descending-specificity": null,
			"no-duplicate-selectors": null,
			"rule-empty-line-before": null,
			"selector-class-pattern": null,
			"selector-id-pattern": null,
			"selector-max-class": null
		}
	},
	"np": {
		"publish": false,
		"releaseDraft": false
	},
	"build": {
		"files": [
			"**/*",
			"!media${/*}"
		],
		"asarUnpack": [
			"static/Icon.png"
		],
		"appId": "com.sindresorhus.caprine",
		"mac": {
			"category": "public.app-category.social-networking",
			"icon": "build/icon.icns",
			"electronUpdaterCompatibility": ">=4.5.2",
			"darkModeSupport": true,
			"target": {
				"target": "default",
				"arch": [
					"x64",
					"arm64"
				]
			},
			"extendInfo": {
				"LSUIElement": 1,
				"NSCameraUsageDescription": "Caprine needs access to your camera.",
				"NSMicrophoneUsageDescription": "Caprine needs access to your microphone."
			}
		},
		"dmg": {
			"iconSize": 160,
			"contents": [
				{
					"x": 180,
					"y": 170
				},
				{
					"x": 480,
					"y": 170,
					"type": "link",
					"path": "/Applications"
				}
			]
		},
		"linux": {
			"target": [
				"AppImage",
				"deb"
			],
			"icon": "build/icons/",
			"synopsis": "Elegant Facebook Messenger desktop app",
			"description": "Caprine is an unofficial and privacy focused Facebook Messenger app with many useful features.",
			"category": "Network;Chat"
		},
		"snap": {
			"plugs": [
				"default",
				"camera",
				"removable-media"
			],
			"publish": [
				{
					"provider": "github"
				},
				{
					"provider": "snapStore",
					"channels": [
						"stable"
					]
				}
			]
		},
		"win": {
			"verifyUpdateCodeSignature": false,
			"icon": "build/icon.png"
		},
		"nsis": {
			"oneClick": false,
			"perMachine": false,
			"allowToChangeInstallationDirectory": true
		}
	},
	"husky": {
		"hooks": {
			"pre-push": "npm test"
		}
	}
}


================================================
FILE: packages/deb/addRepo.sh
================================================
#!/bin/bash
# Made by Lefteris Garyfalakis
# Last update: 25-08-2022

COL_NC='\e[0m' # No Color
COL_LIGHT_RED='\e[1;31m'
COL_LIGHT_GREEN='\e[1;32m'
COL_LIGHT_BLUE='\e[1;94m'
COL_LIGHT_YELLOW='\e[1;93m'
TICK="${COL_NC}[${COL_LIGHT_GREEN}✓${COL_NC}]"
CROSS="${COL_NC}[${COL_LIGHT_RED}✗${COL_NC}]"
INFO="${COL_NC}[${COL_LIGHT_YELLOW}i${COL_NC}]"
QUESTION="${COL_NC}[${COL_LIGHT_BLUE}?${COL_NC}]"

APT_SOURCE_PATH="/etc/apt/sources.list.d/caprine.list"
APT_SOURCE_CONTENT="deb [trusted=yes] https://apt.fury.io/lefterisgar/ * *"

clear

# Print ASCII logo and branding
printf "       ⠀⠀⠀⠀⠀⠀⠀⢀⣠⣤⣤⣶⣶⣶⣶⣤⣤⣄⡀⠀⠀⠀⠀⠀⠀⠀
         ⠀⠀⢀⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣦⡀⠀⠀⠀⠀
       ⠀⠀⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⡀⠀⠀
       ⠀⢀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀⠀
       ⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⢿⣿⣿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⠀
       ⢰⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠀⠀⠙⠿⠿⠛⠉⣠⣾⣿⣿⣿⣿⣿⡆
       ⢸⣿⣿⣿⣿⣿⣿⠟⠁⢀⣠⣄⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣿⣿⣿⡇
       ⠈⣿⣿⣿⣿⣟⣥⣶⣾⣿⣿⣿⣷⣦⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁
       ⠀⠹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠀
       ⠀⠀⠙⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠋⠀⠀
        ⠀⠀⠈⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⠁⠀⠀⠀
        ⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠛⠉⠀⠀⠀⠀⠀⠀
        ⠀⠀⠀⢸⡿⠛⠋\n\n"

printf "      ___               _
     / __|__ _ _ __ _ _(_)_ _  ___
    | (__/ _\` | '_ \ '_| | ' \/ -_)
     \___\__,_| .__/_| |_|_||_\___|
              |_|\n\n"

printf " Elegant Facebook Messenger desktop app\n\n"

printf "*** Caprine installation script ***\n"
printf -- "-----------------------------------\n"
printf "Author      : Lefteris Garyfalakis\n"
printf "Last update : 25-08-2022\n"
printf -- "-----------------------------------\n"

printf "%b %bDetecting APT...%b\\n" "${INFO}"

# Check if APT is installed
if hash apt 2>/dev/null; then
	printf "%b %b$(apt -v)%b\\n" "${TICK}" "${COL_LIGHT_GREEN}" "${COL_NC}"
else
	printf "%b %bAPT was not detected!%b\\n" "${CROSS}" "${COL_LIGHT_RED}" "${COL_NC}"
	printf "%b %bIs your distribution Debian-based? %b" "${QUESTION}"
    exit 2
fi

# Add Caprine's APT repository
printf "%b %bAdding APT repository...%b" "${INFO}"

# Disable globbing
set -f

echo $APT_SOURCE_CONTENT | sudo tee $APT_SOURCE_PATH > /dev/null

# Enable globbing
set +f

if [[ $(< $APT_SOURCE_PATH) == "$APT_SOURCE_CONTENT" ]]; then
	printf " Done!\n"
else
	printf "\n%b %bError adding APT repository!%b\\n" "${CROSS}" "${COL_LIGHT_RED}" "${COL_NC}"
	printf "%b %bReason: Content mismatch%b\\n" "${CROSS}" "${COL_LIGHT_RED}" "${COL_NC}"
	exit 5
fi

# Update the repositories
printf "%b %bUpdating repositories...%b" "${INFO}"

sudo apt update > /dev/null 2>&1

printf " Done!\n"

# Ask the user if he wants to install Caprine
printf "%b %bDo you want to install Caprine? (y/n) %b" "${QUESTION}"
read -n 1 -r < /dev/tty
printf "\n"

if [[ $REPLY =~ ^[Yy]$ ]]; then
	printf "%b %bInstalling Caprine...%b" "${INFO}"

	sudo apt install -y caprine > /dev/null 2>&1

	printf " Done!\n"
else
	printf "%b %bOperation cancelled by the user. Aborting.%b\\n" "${CROSS}" "${COL_LIGHT_RED}" "${COL_NC}"
	exit 125;
fi


================================================
FILE: packages/rpm/caprine.desktop
================================================
[Desktop Entry]
Type=Application
Name=Caprine
GenericName=IM Client
Comment=Elegant Facebook Messenger desktop app
Icon=caprine
Exec=caprine
Keywords=Messenger;Facebook;Chat;
Categories=GTK;InstantMessaging;Network;
StartupNotify=true


================================================
FILE: packages/rpm/caprine.spec
================================================
%define debug_package %{nil}
%global _build_id_links alldebug

Name:           caprine
Version:        2.61.0
Release:        1%{?dist}
Summary:        Elegant Facebook Messenger desktop app

License:        MIT
URL:            https://github.com/sindresorhus/caprine/
Source0:        https://github.com/sindresorhus/caprine/archive/refs/tags/v%{version}.tar.gz
Source1:        %{name}.desktop

ExclusiveArch:  x86_64
BuildRequires:  npm
BuildRequires:  nodejs >= 20.0.0

%description
Caprine is an unofficial and privacy-focused Facebook Messenger app with many useful features.

%prep
%autosetup

%build
npm install --silent --no-progress
node_modules/.bin/tsc
node_modules/.bin/electron-builder --linux dir

%install
install -d %{buildroot}%{_libdir}/%{name}
cp -r dist/linux-unpacked/* %{buildroot}%{_libdir}/%{name}

install -d %{buildroot}%{_bindir}
ln -sf %{_libdir}/%{name}/%{name} %{buildroot}%{_bindir}/%{name}

install -Dm644 build/icon.png %{buildroot}%{_datadir}/pixmaps/%{name}.png

install -d %{buildroot}%{_datadir}/applications
install -Dm644 %{SOURCE1} %{buildroot}%{_datadir}/applications/%{name}.desktop

install -d %{buildroot}%{_datadir}/licenses/%{name}
install -Dm644 license %{buildroot}%{_datadir}/licenses/%{name}

%post
/usr/bin/update-desktop-database
/usr/bin/gtk-update-icon-cache

%postun
/usr/bin/update-desktop-database
/usr/bin/gtk-update-icon-cache

%files
%license %{_datadir}/licenses/%{name}/license
%{_libdir}/%{name}/
%{_bindir}/%{name}
%{_datadir}/applications/caprine.desktop
%{_datadir}/pixmaps/%{name}.png

%changelog
* Wed Apr  3 2024 dusansimic <dusan.simic1810@gmail.com> - 2.60.1-1
- Code refactoring
* Tue Feb 20 2024 dusansimic <dusan.simic1810@gmail.com> - 2.59.3-1
- Fix blank window
- Fix try icon
* Mon Feb 19 2024 dusansimic <dusan.simic1810@gmail.com> - 2.59.2-1
- Hidden dialog issue Fix
- Update Messenger for Mac/Windows selectors
* Wed Oct 11 2023 dusansimic <dusan.simic1810@gmail.com> - 2.59.1-1
- Release 2.59.1
* Wed Sep 27 2023 dusansimic <dusan.simic1810@gmail.com> - 2.59.0-1
- Release 2.59.0
* Mon Sep 25 2023 dusansimic <dusan.simic1810@gmail.com> - 2.58.3-1
- Release 2.58.3
* Mon Sep 25 2023 dusansimic <dusan.simic1810@gmail.com> - 2.58.2-1
- Release 2.58.2
* Tue Sep  5 2023 dusansimic <dusan.simic1810@gmail.com> - 2.58.1-1
- Release 2.58.1
* Wed Jul 26 2023 dusansimic <dusan.simic1810@gmail.com> - 2.58.0-1
- Release 2.58.0
* Sat May  6 2023 dusansimic <dusan.simic1810@gmail.com> - 2.57.4-1
- Release 2.57.4
* Sun Apr 30 2023 dusansimic <dusan.simic1810@gmail.com> - 2.57.3-1
- Release 2.57.3
* Sat Apr 29 2023 dusansimic <dusan.simic1810@gmail.com> - 2.57.2-1
- Release 2.57.2
* Mon Apr 17 2023 dusansimic <dusan.simic1810@gmail.com> - 2.57.1-1
- Release 2.57.1
* Wed Nov 16 2022 dusansimic <dusan.simic1810@gmail.com> - 2.57.0-1
- Release 2.57.0
* Mon Aug 22 2022 dusansimic <dusan.simic1810@gmail.com> - 2.56.1-1
- Release 2.56.1
* Thu Aug 18 2022 dusansimic <dusan.simic1810@gmail.com> - 2.56.0-1
- Release 2.56.0
* Thu Jun 16 2022 dusansimic <dusan.simic1810@gmail.com> - 2.55.7-1
- Release 2.55.7
* Mon Jun 13 2022 dusansimic <dusan.simic1810@gmail.com> - 2.55.6-1
- Release 2.55.6
* Mon May 16 2022 dusansimic <dusan.simic1810@gmail.com> - 2.55.5-1
- Release 2.55.5
* Sun Mar 20 2022 dusansimic <dusan.simic1810@gmail.com> - 2.55.3-1
- Release 2.55.3
* Thu Dec  9 2021 dusansimic <dusan.simic1810@gmail.com> - 2.55.2-1
- Release 2.55.2
* Thu Dec  2 2021 dusansimic <dusan.simic1810@gmail.com> - 2.55.1-1
- Release 2.55.1
* Thu Oct 28 2021 dusansimic <dusan.simic1810@gmail.com> - 2.55.0-1
- Release 2.55.0
* Fri Aug 13 2021 dusansimic <dusan.simic1810@gmail.com> - 2.54.1-1
- Release 2.54.1
* Thu Jul 29 2021 dusansimic <dusan.simic1810@gmail.com> - 2.54.0-1
- Release 2.54.0
* Sat May  8 2021 dusansimic <dusan.simic1810@gmail.com> - 2.53.0-1
- Release 2.53.0
* Mon Apr 26 2021 dusansimic <dusan.simic1810@gmail.com> - 2.52.4-1
- Release 2.52.4
- Removed dependency desktop-file-utils and gtk-update-icon-cache
* Fri Apr  9 2021 dusansimic <dusan.simic1810@gmail.com> - 2.52.3-1
- Release 2.52.3
- Some minor updates to spec file and adding license file to installation
* Thu Mar 25 2021 dusansimic <dusan.simic1810@gmail.com> - 2.52.2-1
- Release 2.52.2


================================================
FILE: patches/electron-debug++electron-is-dev+1.2.0.patch
================================================
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
index 3b3fbc5..042a8a5 100644
--- a/node_modules/electron-debug/node_modules/electron-is-dev/index.js
+++ b/node_modules/electron-debug/node_modules/electron-is-dev/index.js
@@ -5,7 +5,7 @@ if (typeof electron === 'string') {
 	throw new TypeError('Not running in an Electron environment!');
 }
 
-const app = electron.app || electron.remote.app;
+const app = electron.app || require('@electron/remote');
 
 const isEnvSet = 'ELECTRON_IS_DEV' in process.env;
 const getFromEnv = parseInt(process.env.ELECTRON_IS_DEV, 10) === 1;


================================================
FILE: patches/electron-util++electron-is-dev+1.2.0.patch
================================================
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
index 3b3fbc5..042a8a5 100644
--- a/node_modules/electron-util/node_modules/electron-is-dev/index.js
+++ b/node_modules/electron-util/node_modules/electron-is-dev/index.js
@@ -5,7 +5,7 @@ if (typeof electron === 'string') {
 	throw new TypeError('Not running in an Electron environment!');
 }
 
-const app = electron.app || electron.remote.app;
+const app = electron.app || require('@electron/remote');
 
 const isEnvSet = 'ELECTRON_IS_DEV' in process.env;
 const getFromEnv = parseInt(process.env.ELECTRON_IS_DEV, 10) === 1;


================================================
FILE: readme.md
================================================
<div align="center">
	<br>
	<br>
	<a href="https://github.com/sindresorhus/caprine">
		<img src="media/AppIcon-readme.png" width="200" height="200">
	</a>
	<h1>Caprine</h1>
	<p>
		<b>Elegant Facebook Messenger desktop app</b>
	</p>
	<br>
	<br>
	<p>
		Caprine is an unofficial and privacy-focused Facebook Messenger app with many useful features.
	</p>
	<b>
		Caprine is feature complete. However, we welcome contributions for improvements and bug fixes.
	</b>
	<br>
		<a href="https://github.com/sindresorhus/caprine">
		Website
		</a>
	<br>
	<a href="https://github.com/sindresorhus/caprine/releases/latest">
		<img src="media/screenshot.png" width="846">
	</a>
</div>

## Highlights

- [Dark theme](#dark-mode)
- [Vibrant theme](#vibrancy-macos-only)\*
- [Privacy-focused](#privacy)
- [Keyboard shortcuts](#keyboard-shortcuts)
- [Menu bar mode](#menu-bar-mode-macos-only-)\*
- [Work Chat support](#work-chat-support)
- [Code blocks](#code-blocks)
- [Touch Bar support](#touch-bar-support-macos-only)\*
- [Custom styles](#custom-styles)
- Cross-platform
- Silent auto-updates
- Custom text size
- Emoji style setting
- Respects Do Not Disturb\*

\*macOS only

## Install

*macOS 10.12+ (Intel and Apple Silicon), Linux (x64 and arm64), and Windows 10+ (64-bit) are supported.*

Download the latest version on the [website](https://github.com/sindresorhus/caprine) or below.

### macOS

[**Download**](https://github.com/sindresorhus/caprine/releases/latest) the `.dmg` file.

Or with [Homebrew](https://brew.sh): `$ brew install caprine`

### Linux

<table>
	<th>Distribution</th>
	<th>Repository</th>
	<th>Automatic Updates</th>
	<th>Maintainer</th>
	<th>How to install</th>
	<tr>
		<td>Arch Linux</td>
		<td>Community</td>
		<td align="center">✔️</td>
		<td>Frederik Schwan</td>
		<td><code>pacman -S caprine</code></td>
	</tr>
	<tr>
		<td>Debian / Ubuntu (manually)</td>
		<td>GitHub</td>
		<td align="center">❌</td>
		<td>Official</td>
		<td>
			<a href="https://github.com/sindresorhus/caprine/releases/latest">Download</a> the .deb file
		</td>
	</tr>
	<tr>
		<td>Debian / Ubuntu (deb-get)</td>
		<td>GitHub</td>
		<td align="center">✔️</td>
		<td>Official</td>
		<td>
			Follow the <a href=#installation-using-deb-get>instructions below</a>
		</td>
	</tr>
	<tr>
		<td>Debian / Ubuntu (APT)</td>
		<td>Gemfury</td>
		<td align="center">✔️</td>
		<td>Lefteris Garyfalakis</td>
		<td>
			Follow the <a href=#apt-repository-gemfury>instructions below</a>
		</td>
	</tr>
	<tr>
		<td>RHEL / Fedora / openSUSE</td>
		<td>Copr</td>
		<td align="center">✔️</td>
		<td>Dušan Simić</td>
		<td>
			Follow the <a href=#copr>instructions below</a>
		</td>
	</tr>
	<tr>
		<td>AppImage</td>
		<td>GitHub</td>
		<td align="center">✔️</td>
		<td>Official</td>
		<td>
			Follow the <a href=#appimage>instructions below</a>
		</td>
	</tr>
	<tr>
		<td>Flatpak</td>
		<td>Flathub</td>
		<td align="center">✔️</td>
		<td>Dušan Simić</td>
		<td>
			Visit <a href="https://flathub.org/apps/details/com.sindresorhus.Caprine">Flathub</a> and follow the instructions
		</td>
	</tr>
	<tr>
		<td>Snap</td>
		<td>Snapcraft</td>
		<td align="center">✔️</td>
		<td>Official</td>
		<td>
			Visit <a href="https://snapcraft.io/caprine">Snapcraft</a> and follow the instructions
		</td>
	</tr>
</table>

#### Installation using deb-get:

* Download and install [deb-get](https://github.com/wimpysworld/deb-get).
* Run `deb-get install caprine`.

Note: deb-get is 3rd party software, not to be associated with apt-get.

#### APT repository (Gemfury):

Run the following command to add it:

```sh
wget -q -O- https://raw.githubusercontent.com/sindresorhus/caprine/main/packages/deb/addRepo.sh | sudo bash
```

Alternatively (for advanced users):
```sh
# Add the repository
echo "deb [trusted=yes] https://apt.fury.io/lefterisgar/ * *" > \
/etc/apt/sources.list.d/caprine.list

# Update the package indexes
sudo apt update

# Install Caprine
sudo apt install caprine
```


#### Copr:

For Fedora / RHEL:

```sh
sudo dnf copr enable dusansimic/caprine
sudo dnf install caprine
```

For openSUSE:
- Create a new file in `/etc/zypp/repos.d/caprine.repo`.
- 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.

Alternatively use the following one-liner:
```sh
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
```

#### AppImage:

[Download](https://github.com/sindresorhus/caprine/releases/latest) the `.AppImage` file.

Make it [executable](https://discourse.appimage.org/t/how-to-run-an-appimage/80):

```sh
chmod +x Caprine-2.xx.x.AppImage
```

Then run it!

#### About immutable Linux distributions:
[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.*

*Note: On some distributions Flatpak must be [pre-configured manually](https://flatpak.org/setup).*

### Windows

<table>
	<th>Method</th>
	<th>Repository</th>
	<th>Automatic Updates</th>
	<th>Maintainer</th>
	<th>How to install</th>
	<tr>
		<td>Manually</td>
		<td>GitHub</td>
		<td align="center">❌</td>
		<td>Official</td>
		<td>
			<a href="https://github.com/sindresorhus/caprine/releases/latest">Download</a> the .exe file
		</td>
	</tr>
	<tr>
		<td>Chocolatey</td>
		<td>Community</td>
		<td align="center">✔️</td>
		<td>Michael Quevillon</td>
		<td><code>choco install caprine</code></td>
	</tr>
</table>

*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).*

## Features

### Dark mode

You can toggle dark mode in the `View` menu or with <kbd>Command</kbd> <kbd>d</kbd> / <kbd>Control</kbd> <kbd>d</kbd>.

<img src="media/screenshot-dark.png" width="846">

### Hide Names and Avatars

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 <kbd>Command/Control</kbd> <kbd>Shift</kbd> <kbd>n</kbd>.

### Vibrancy *(macOS only)*

On *macOS*, you can toggle the window vibrancy effect in the `View` menu.

<img src="media/screenshot-vibrancy.jpg" width="1165">

### Privacy

<img src="media/screenshot-block-typing-indicator.png" width="626">

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.

### Mute desktop notifications *(macOS only)*

You can quickly disable receiving notifications from the `Caprine`/`File` menu or the Dock on macOS.

### Hide notification message preview

<div align="center"><img src="media/screenshot-hide-notification-message-location.png" width="300"></div>

<div align="center"><img src="media/screenshot-hide-notification-message-before.png" width="400"></div>

<div align="center"><img src="media/screenshot-hide-notification-message-after.png" width="400"></div>

You can toggle the `Show Message Preview in Notification` setting in the `Caprine`/`File` menu.

### Prevents link tracking

Links that you click on will not be tracked by Facebook.

### Jump to conversation hotkey

You can switch conversations similar to how you switch browser tabs: <kbd>Command/Control</kbd> <kbd>n</kbd> (where `n` is `1` through `9`).

### Compact mode

The interface adapts when resized to a small size.

<div align="center"><img src="media/screenshot-compact.png" width="512"></div>

### Desktop notifications

Desktop notifications can be turned on in `Preferences`.

<div align="center"><img src="media/screenshot-notification.png" width="358"></div>

### Always on Top

You can toggle whether Caprine stays on top of other windows in the `Window`/`View` menu or with <kbd>Command/Control</kbd> <kbd>Shift</kbd> <kbd>t</kbd>.

### Work Chat support

Support for Work Chat: Messenger for [Workplace](https://www.facebook.com/workplace). You can switch to it in the `Caprine`/`File` menu.

<div align="center"><img src="media/screenshot-work-chat.png" width="788"></div>

### Code blocks

You can send code blocks by using [Markdown syntax](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet#code).

<div align="center"><img src="media/screenshot-codeblocks-dark.png" width="784"></div>
<div align="center"><img src="media/screenshot-codeblocks-light.png" width="784"></div>

### Background behavior

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.

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`.

### Quick access to conversations from the Dock menu *(macOS only)*

<img src="media/screenshot-dock-menu.png" width="319" height="404">

### Touch Bar support *(macOS only)*

<img src="media/screenshot-touchbar.png" width="1085">

### Custom languages for spell-check *(Not for macOS)*

Users can select supported languages from `Conversation` → `Spell Checker Language`.

macOS detects the language automatically.

### Custom styles

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.

### Menu Bar Mode *(macOS only)* <img src="media/screenshot-menu-bar-mode.png" width="20">

<img src="media/screenshot-menu-bar-menu.png" width="140" align="right">

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 <kbd>Command</kbd> <kbd>Shift</kbd> <kbd>y</kbd>.

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.

### Keyboard shortcuts

Description            | Keys
-----------------------| -----------------------
New conversation       | <kbd>Command/Control</kbd> <kbd>n</kbd>
Search conversations   | <kbd>Command/Control</kbd> <kbd>k</kbd>
Toggle "Dark mode"     | <kbd>Command/Control</kbd> <kbd>d</kbd>
Hide Names and Avatars | <kbd>Command/Control</kbd> <kbd>Shift</kbd> <kbd>n</kbd>
Next conversation      | <kbd>Command/Control</kbd> <kbd>]</kbd> or <kbd>Control</kbd> <kbd>Tab</kbd>
Previous conversation  | <kbd>Command/Control</kbd> <kbd>[</kbd> or <kbd>Control</kbd> <kbd>Shift</kbd> <kbd>Tab</kbd>
Jump to conversation   | <kbd>Command/Control</kbd> <kbd>1</kbd>…<kbd>9</kbd>
Insert GIF             | <kbd>Command/Control</kbd> <kbd>g</kbd>
Insert sticker         | <kbd>Command/Control</kbd> <kbd>s</kbd>
Insert emoji           | <kbd>Command/Control</kbd> <kbd>e</kbd>
Attach files           | <kbd>Command/Control</kbd> <kbd>t</kbd>
Focus text input       | <kbd>Command/Control</kbd> <kbd>i</kbd>
Search in conversation | <kbd>Command/Control</kbd> <kbd>f</kbd>
Mute conversation      | <kbd>Command/Control</kbd> <kbd>Shift</kbd> <kbd>m</kbd>
Hide conversation      | <kbd>Command/Control</kbd> <kbd>Shift</kbd> <kbd>h</kbd>
Delete conversation    | <kbd>Command/Control</kbd> <kbd>Shift</kbd> <kbd>d</kbd>
Toggle "Always on Top" | <kbd>Command/Control</kbd> <kbd>Shift</kbd> <kbd>t</kbd>
Toggle window menu     | <kbd>Alt</kbd> *(Windows/Linux only)*
Toggle main window     | <kbd>Command</kbd> <kbd>Shift</kbd> <kbd>y</kbd> *(macOS only)*
Toggle sidebar         | <kbd>Command/Control</kbd> <kbd>Shift</kbd> <kbd>s</kbd>
Switch to Messenger    | <kbd>Command/Control</kbd> <kbd>Shift</kbd> <kbd>1</kbd>
Switch to Workchat     | <kbd>Command/Control</kbd> <kbd>Shift</kbd> <kbd>2</kbd>
Preferences            | <kbd>Command/Control</kbd> <kbd>,</kbd>

###### Tip

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.

## FAQ

#### Can I contribute localizations?

The main app interface is already localized by Facebook. The app menus are not localized, and we're not interested in localizing those.

---

## Dev

Built with [Electron](https://electronjs.org).

### Run

```sh
npm install && npm start
```

### Build

See the [`electron-builder` docs](https://www.electron.build/multi-platform-build).

### Publish

```sh
npm run release
```

Then edit the automatically created GitHub Releases draft and publish.

## Maintainers

- [Dušan Simić](https://github.com/dusansimic)
- [Lefteris Garyfalakis](https://github.com/lefterisgar)
- [Michael Quevillon](https://github.com/mquevill)
- [Nikolas Spiridakis](https://github.com/1nikolas)

**Former**

- [Jarek Radosz](https://github.com/CvX)

## Links

- [Product Hunt post](https://www.producthunt.com/posts/caprine-2)

## Press

- [The Essential Windows Apps for 2018 - Lifehacker](https://lifehacker.com/lifehacker-pack-for-windows-our-list-of-the-essential-1828117805)
- [Caprine review: Customize Facebook Messenger on Windows 10 - Windows Central](https://www.windowscentral.com/caprine-review-customizing-facebook-messenger-windows-10)

## Disclaimer

Caprine is a third-party app and is not affiliated with Facebook.


================================================
FILE: source/autoplay.ts
================================================
import {ipcRenderer as ipc} from 'electron-better-ipc';
import selectors from './browser/selectors';

const conversationId = 'conversationWindow';
const disabledVideoId = 'disabled_autoplay';

export async function toggleVideoAutoplay(): Promise<void> {
	const autoplayVideos = await ipc.callMain<undefined, boolean>('get-config-autoplayVideos');
	if (autoplayVideos) {
		// Stop the observers
		conversationDivObserver.disconnect();
		videoObserver.disconnect();

		// Revert previous changes
		enableVideoAutoplay();
	} else {
		// Start the observer
		startConversationWindowObserver();

		// Trigger once manually before observers kick in
		disableVideoAutoplay(getVideos());
	}
}

// Hold reference to videos the user has started playing
// Enables us to check if the video is autoplaying, for example, when changing conversation
const playedVideos: HTMLVideoElement[] = [];

function disableVideoAutoplay(videos: NodeListOf<HTMLVideoElement>): void {
	for (const video of videos) {
		// Don't disable currently playing videos
		if (playedVideos.includes(video)) {
			continue;
		}

		const firstParent = video.parentElement!;

		// Video parent element which has a snapshot of the video as a background image
		const parentWithBackground = video.parentElement!.parentElement!.parentElement!;

		// Hold reference to the background parent so we can revert our changes
		const parentWithBackgroundParent = parentWithBackground.parentElement!;

		// Reference to the original play icon on top of the video
		const playIcon = video.nextElementSibling!.nextElementSibling! as HTMLElement;
		// If the video is playing, the icon is hidden
		playIcon.classList.remove('hidden_elem');

		// Set the `id` so we can easily trigger a click-event when reverting changes
		playIcon.setAttribute('id', disabledVideoId);

		const {
			style: {width, height},
		} = firstParent;

		const style = parentWithBackground.style || window.getComputedStyle(parentWithBackground);
		const backgroundImageSource = style.backgroundImage.slice(4, -1).replaceAll(/"/, '');

		// Create the image to replace the video as a placeholder
		const image = document.createElement('img');
		image.setAttribute('src', backgroundImageSource);
		image.classList.add('disabledAutoPlayImgTopRadius');

		// If it's a video without a source title bar at the bottom,
		// round the bottom part of the video
		if (parentWithBackgroundParent.childElementCount === 1) {
			image.classList.add('disabledAutoPlayImgBottomRadius');
		}

		image.setAttribute('height', height);
		image.setAttribute('width', width);

		// Create a separate instance of the play icon
		// Clone the existing icon to get the original events
		// Without creating a new icon, Messenger auto-hides the icon when scrolled to the video
		const copiedPlayIcon = playIcon.cloneNode(true) as HTMLElement;

		// Remove the image and the new play icon and append the original divs
		// We can enable autoplay again by triggering this event
		copiedPlayIcon.addEventListener('play', () => {
			image.remove();
			copiedPlayIcon.remove();
			parentWithBackgroundParent.prepend(parentWithBackground);
		});

		// Separate handler for `click` so we know if it was the user who played the video
		copiedPlayIcon.addEventListener('click', () => {
			playedVideos.push(video);
			const event = new Event('play');
			copiedPlayIcon.dispatchEvent(event);
			// Sometimes the video doesn't start playing even though we trigger the click event
			// As a workaround, check if the video didn't start playing and manually trigger
			// the click event
			setTimeout(() => {
				if (video.paused) {
					playIcon.click();
				}
			}, 50);
		});

		parentWithBackgroundParent.prepend(image);
		parentWithBackgroundParent.prepend(copiedPlayIcon);
		parentWithBackground.remove();
	}
}

// If we previously disabled autoplay on videos,
// trigger the `copiedPlayIcon` click event to revert changes
function enableVideoAutoplay(): void {
	const playIcons = document.querySelectorAll(`#${disabledVideoId}`);
	for (const icon of playIcons) {
		const event = new Event('play');
		icon.dispatchEvent(event);
	}
}

function getVideos(): NodeListOf<HTMLVideoElement> {
	return document.querySelectorAll('video');
}

function startConversationWindowObserver(): void {
	conversationDivObserver.observe(document.documentElement, {
		childList: true,
		subtree: true,
	});
}

function startVideoObserver(element: Element): void {
	videoObserver.observe(element, {
		childList: true,
		subtree: true,
	});
}

// A way to hold reference to conversation part of the document
// Used to refresh `videoObserver` after the conversation reference is lost
let conversationWindow: Element;
const conversationDivObserver = new MutationObserver(_ => {
	let conversation = document.querySelector(`#${conversationId}`);

	// Fetch it using `querySelector` if no luck with the `conversationId`
	conversation ||= document.querySelector(selectors.conversationSelector);

	// If we have a new reference
	if (conversation && conversationWindow !== conversation) {
		// Add `conversationId` so we know when we've lost the reference to
		// the `conversationWindow` and we can restart the video observer
		conversation.id = conversationId;
		conversationWindow = conversation;
		startVideoObserver(conversationWindow);
	}
});

// Reference to mutation observer
// Only active if the user has set option to disable video autoplay
const videoObserver = new MutationObserver(_ => {
	// Select by tag instead of iterating over mutations which is more performant
	const videos = getVideos();
	// If videos were added disable autoplay
	if (videos.length > 0) {
		disableVideoAutoplay(videos);
	}
});


================================================
FILE: source/browser/conversation-list.ts
================================================
import {ipcRenderer as ipc} from 'electron-better-ipc';
import elementReady from 'element-ready';
import {isNull} from 'lodash';
import selectors from './selectors';

const icon = {
	read: 'data-caprine-icon',
	unread: 'data-caprine-icon-unread',
};

const padding = {
	top: 3,
	right: 0,
	bottom: 3,
	left: 0,
};

function drawIcon(size: number, img?: HTMLImageElement): HTMLCanvasElement {
	const canvas = document.createElement('canvas');

	if (img) {
		canvas.width = size + padding.left + padding.right;
		canvas.height = size + padding.top + padding.bottom;

		const context = canvas.getContext('2d')!;
		context.beginPath();
		context.arc((size / 2) + padding.left, (size / 2) + padding.top, (size / 2), 0, Math.PI * 2, true);
		context.closePath();
		context.clip();

		context.drawImage(img, padding.left, padding.top, size, size);
	} else {
		canvas.width = 0;
		canvas.height = 0;
	}

	return canvas;
}

// Return canvas with rounded image
async function urlToCanvas(url: string, size: number): Promise<HTMLCanvasElement> {
	return new Promise(resolve => {
		const img = new Image();

		img.setAttribute('crossorigin', 'anonymous');

		img.addEventListener('load', () => {
			resolve(drawIcon(size, img));
		});

		img.addEventListener('error', () => {
			console.error('Image not found', url);
			resolve(drawIcon(size));
		});

		img.src = url;
	});
}

async function createIcons(element: HTMLElement, url: string): Promise<void> {
	const canvas = await urlToCanvas(url, 50);

	element.setAttribute(icon.read, canvas.toDataURL());

	const markerSize = 8;
	const context = canvas.getContext('2d')!;

	context.fillStyle = '#f42020';
	context.beginPath();
	context.ellipse(canvas.width - markerSize, markerSize, markerSize, markerSize, 0, 0, 2 * Math.PI);
	context.closePath();
	context.fill();

	element.setAttribute(icon.unread, canvas.toDataURL());
}

async function discoverIcons(element: HTMLElement): Promise<void> {
	if (element) {
		return createIcons(element, element.getAttribute('src')!);
	}

	console.warn('Could not discover profile picture. Falling back to default image.');

	// Fall back to messenger favicon
	const messengerIcon = document.querySelector('link[rel~="icon"]');

	if (messengerIcon) {
		return createIcons(element, messengerIcon.getAttribute('href')!);
	}

	// Fall back to facebook favicon
	return createIcons(element, 'https://facebook.com/favicon.ico');
}

async function getIcon(element: HTMLElement, unread: boolean): Promise<string> {
	if (element === null) {
		return icon.read;
	}

	if (!element.getAttribute(icon.read)) {
		await discoverIcons(element);
	}

	return element.getAttribute(unread ? icon.unread : icon.read)!;
}

async function getLabel(element: HTMLElement): Promise<string> {
	if (isNull(element)) {
		return '';
	}

	const emojis: HTMLElement[] = [];
	if (element !== null) {
		for (const elementCurrent of element.children) {
			emojis.push(elementCurrent as HTMLElement);
		}
	}

	for (const emoji of emojis) {
		emoji.outerHTML = emoji.querySelector('img')?.getAttribute('alt') ?? '';
	}

	return element.textContent ?? '';
}

async function createConversationNewDesign(element: HTMLElement): Promise<Conversation> {
	const conversation: Partial<Conversation> = {};
	// TODO: Exclude muted conversations
	/*
	const muted = Boolean(element.querySelector(selectors.muteIconNewDesign));
	*/

	conversation.selected = Boolean(element.querySelector('[role=row] [role=link] > div:only-child'));
	conversation.unread = Boolean(element.querySelector('[aria-label="Mark as Read"]'));

	const unparsedLabel = element.querySelector<HTMLElement>('.a8c37x1j.ni8dbmo4.stjgntxs.l9j0dhe7 > span > span')!;
	conversation.label = await getLabel(unparsedLabel);

	const iconElement = element.querySelector<HTMLElement>('img')!;
	conversation.icon = await getIcon(iconElement, conversation.unread);

	return conversation as Conversation;
}

async function createConversationList(): Promise<Conversation[]> {
	const conversationListSelector = selectors.conversationList;

	const list = await elementReady(conversationListSelector, {
		stopOnDomReady: false,
	});

	if (!list) {
		console.error('Could not find conversation list', conversationListSelector);
		return [];
	}

	const elements: HTMLElement[] = [...list.children] as HTMLElement[];

	// Remove last element from childer list
	elements.splice(-1, 1);

	const conversations: Conversation[] = await Promise.all(elements.map(async element => createConversationNewDesign(element)));

	return conversations;
}

export async function sendConversationList(): Promise<void> {
	const conversationsToRender: Conversation[] = await createConversationList();
	ipc.callMain('conversations', conversationsToRender);
}

function generateStringFromNode(element: Element): string | undefined {
	const cloneElement = element.cloneNode(true) as Element;
	let emojiString;

	const images = cloneElement.querySelectorAll('img');
	for (const image of images) {
		emojiString = image.alt;
		// Replace facebook's thumbs up with emoji
		if (emojiString === '(Y)' || emojiString === '(y)') {
			emojiString = '👍';
		}

		image.parentElement?.replaceWith(document.createTextNode(emojiString));
	}

	return cloneElement.textContent ?? undefined;
}

function countUnread(mutationsList: MutationRecord[]): void {
	const alreadyChecked: string[] = [];

	const unreadMutations = mutationsList.filter(mutation =>
		// When a conversations "becomes unread".
		(
			mutation.type === 'childList'
			&& mutation.addedNodes.length > 0
			&& ((mutation.addedNodes[0] as Element).className === selectors.conversationSidebarUnreadDot)
		)
		// When text is received
		|| (
			mutation.type === 'characterData'
			// Make sure the text corresponds to a conversation
			&& mutation.target.parentElement?.parentElement?.parentElement?.className === selectors.conversationSidebarTextParent
		)
		// When an emoji is received, node(s) are added
		|| (
			mutation.type === 'childList'
			// Make sure the mutation corresponds to a conversation
			&& mutation.target.parentElement?.parentElement?.className === selectors.conversationSidebarTextParent
		)
		// Emoji change
		|| (
			mutation.type === 'attributes'
			&& mutation.target.parentElement?.parentElement?.parentElement?.parentElement?.className === selectors.conversationSidebarTextParent
		));

	// Check latest mutation first
	for (const mutation of unreadMutations.reverse()) {
		const current = (mutation.target.parentElement as Element).closest(selectors.conversationSidebarSelector)!;

		const href = current.closest('[role="link"]')?.getAttribute('href');

		if (!href) {
			continue;
		}

		// It is possible to have multiple mutations for the same conversation, but we only want one notification.
		// So if the current conversation has already been checked, continue.
		// Additionally if the conversation is not unread, then also continue.
		if (alreadyChecked.includes(href) || !current.querySelector('.' + selectors.conversationSidebarUnreadDot.replaceAll(/ /, '.'))) {
			continue;
		}

		alreadyChecked.push(href);

		// Get the image data URI from the parent of the author/text
		const imgUrl = current.querySelector('img')?.dataset.caprineIcon;
		const textOptions = current.querySelectorAll(selectors.conversationSidebarTextSelector);
		// Get the author and text of the new message
		const titleTextNode = textOptions[0];
		const bodyTextNode = textOptions[1];

		const titleText = generateStringFromNode(titleTextNode);
		const bodyText = generateStringFromNode(bodyTextNode);

		if (!bodyText || !titleText || !imgUrl) {
			continue;
		}

		// Send a notification
		ipc.callMain('notification', {
			id: 0,
			title: titleText,
			body: bodyText,
			icon: imgUrl,
			silent: false,
		});
	}
}

async function updateTrayIcon(): Promise<void> {
	let messageCount = 0;

	await elementReady(selectors.chatsIcon, {stopOnDomReady: false});

	// Count unread messages in Chats, Marketplace, etc.
	for (const element of document.querySelectorAll<HTMLElement>(selectors.chatsIcon)) {
		// Extract messageNumber from ariaLabel
		const messageNumber = element?.ariaLabel?.match(/\d+/g);

		if (messageNumber) {
			messageCount += Number.parseInt(messageNumber[0], 10);
		}
	}

	ipc.callMain('update-tray-icon', messageCount);
}

window.addEventListener('load', async () => {
	const sidebar = await elementReady('[role=navigation]:has([role=grid])', {stopOnDomReady: false});
	const leftSidebar = await elementReady(`${selectors.leftSidebar}:has(${selectors.chatsIcon})`, {stopOnDomReady: false});

	if (sidebar) {
		const conversationListObserver = new MutationObserver(async () => sendConversationList());
		const conversationCountObserver = new MutationObserver(countUnread);

		conversationListObserver.observe(sidebar, {
			subtree: true,
			childList: true,
			attributes: true,
			attributeFilter: ['class'],
		});

		conversationCountObserver.observe(sidebar, {
			characterData: true,
			subtree: true,
			childList: true,
			attributes: true,
			attributeFilter: ['src', 'alt'],
		});
	}

	if (leftSidebar) {
		const chatsIconObserver = new MutationObserver(async () => updateTrayIcon());

		chatsIconObserver.observe(leftSidebar, {
			subtree: true,
			childList: true,
			attributes: true,
			attributeFilter: ['aria-label'],
		});
	}
});


================================================
FILE: source/browser/selectors.ts
================================================
export default {
	leftSidebar: '[role="navigation"][class="x9f619 x1n2onr6 x1ja2u2z x78zum5 xdt5ytf x2lah0s x193iq5w xeuugli"] > div > div', // ! Tray icon dependency
	chatsIcon: '[class="x9f619 x1n2onr6 x1ja2u2z x78zum5 xdt5ytf x2lah0s x193iq5w xdj266r"] a', // ! Tray icon dependency
	conversationList: '[role=navigation] [role=grid] [class="x1n2onr6"]',
	conversationSelector: '[role=main] [role=grid]',
	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',
	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)
	conversationSidebarTextSelector: '[class="x1lliihq x193iq5w x6ikm8r x10wlt62 xlyipyv xuxw1ft"]', // Generic selector for the text contents of all conversations
	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)
	notificationCheckbox: '._374b:nth-of-type(4) ._4ng2 input',
	rightSidebarMenu: '.x6s0dn4.x3nfvp2.x1fgtraw.xl56j7k.x1n2onr6.xgd8bvy',
	rightSidebarButtons: '.x9f619.x1ja2u2z.x78zum5.x2lah0s.x1n2onr6.xl56j7k.x1qjc9v5.xozqiw3.x1q0g3np.xn6708d.x1ye3gou.x1cnzs8.xdj266r.x11i5rnm.xat24cr.x1mh8g0r > div [role=button]',
	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"]',
	// ! Very fragile selector (most likely cause of hidden dialog issue)
	closePreferencesButton: 'div[role=dialog] > div > div > div:nth-child(2) > [role=button]',
	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',
	userMenuNewSidebar: '[role=navigation]  > div >  div:nth-child(2) > div > div > div:nth-child(1) [role=button]',
	viewsMenu: '.x9f619.x1n2onr6.x1ja2u2z.x78zum5.xdt5ytf.x2lah0s.x193iq5w.xdj266r',
	selectedConversation: '[role=navigation] [role=grid] [role=row] [role=gridcell] [role=link][aria-current=page]',
	// ! Very fragile selector (most likely cause of hidden dialog issue)
	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)',
	// TODO: Fix this selector for new design
	messengerSoundsSelector: '._374d ._6bkz',
	conversationMenuSelectorNewDesign: '[role=menu]',
};


================================================
FILE: source/browser-call.ts
================================================
import elementReady from 'element-ready';

(async () => {
	const startCallButton = (await elementReady<HTMLElement>('._3quh._30yy._2t_', {
		stopOnDomReady: false,
	}))!;

	startCallButton.click();
})();


================================================
FILE: source/browser.ts
================================================
import process from 'node:process';
import {ipcRenderer as ipc} from 'electron-better-ipc';
import {is} from 'electron-util';
import elementReady from 'element-ready';
import {nativeTheme} from '@electron/remote';
import selectors from './browser/selectors';
import {toggleVideoAutoplay} from './autoplay';
import {sendConversationList} from './browser/conversation-list';
import {IToggleSounds} from './types';

async function withMenu(
	menuButtonElement: HTMLElement,
	callback: () => Promise<void> | void,
): Promise<void> {
	const {classList} = document.documentElement;

	// Prevent the dropdown menu from displaying
	classList.add('hide-dropdowns');

	// Click the menu button
	menuButtonElement.click();

	// Wait for the menu to close before removing the 'hide-dropdowns' class
	await elementReady('.x78zum5.xdt5ytf.x1n2onr6.xat3117.xxzkxad > div:nth-child(2) > div', {stopOnDomReady: false});
	const menuLayer = document.querySelector('.x78zum5.xdt5ytf.x1n2onr6.xat3117.xxzkxad > div:nth-child(2) > div');

	if (menuLayer) {
		const observer = new MutationObserver(() => {
			if (!menuLayer.hasChildNodes()) {
				classList.remove('hide-dropdowns');
				observer.disconnect();
			}
		});
		observer.observe(menuLayer, {childList: true});
	} else {
		// Fallback in case .uiContextualLayerPositioner is missing
		classList.remove('hide-dropdowns');
	}

	await callback();
}

async function isNewSidebar(): Promise<boolean> {
	// TODO: stopOnDomReady might not be needed
	await elementReady(selectors.leftSidebar, {stopOnDomReady: false});

	const sidebars = document.querySelectorAll<HTMLElement>(selectors.leftSidebar);

	return sidebars.length === 2;
}

async function withSettingsMenu(callback: () => Promise<void> | void): Promise<void> {
	// Wait for navigation pane buttons to show up
	const settingsMenu = await elementReady(selectors.userMenuNewSidebar, {stopOnDomReady: false});

	await withMenu(settingsMenu as HTMLElement, callback);
}

async function selectMenuItem(itemNumber: number): Promise<void> {
	let selector;

	// Wait for menu to show up
	await elementReady(selectors.conversationMenuSelectorNewDesign, {stopOnDomReady: false});

	const items = document.querySelectorAll<HTMLElement>(
		`${selectors.conversationMenuSelectorNewDesign} [role=menuitem]`,
	);

	// Negative items will select from the end
	if (itemNumber < 0) {
		selector = -itemNumber <= items.length ? items[items.length + itemNumber] : null;
	} else {
		selector = itemNumber <= items.length ? items[itemNumber - 1] : null;
	}

	if (selector) {
		selector.click();
	}
}

async function selectOtherListViews(itemNumber: number): Promise<void> {
	// In case one of other views is shown
	clickBackButton();

	const newSidebar = await isNewSidebar();

	if (newSidebar) {
		const items = document.querySelectorAll<HTMLElement>(
			`${selectors.viewsMenu} span > a`,
		);

		const selector = itemNumber <= items.length ? items[itemNumber - 1] : null;

		if (selector) {
			selector.click();
		}
	} else {
		await withSettingsMenu(() => {
			selectMenuItem(itemNumber);
		});
	}
}

function clickBackButton(): void {
	const backButton = document.querySelector<HTMLElement>('._30yy._2oc9');

	if (backButton) {
		backButton.click();
	}
}

ipc.answerMain('show-preferences', async () => {
	if (isPreferencesOpen()) {
		return;
	}

	await openPreferences();
});

ipc.answerMain('new-conversation', async () => {
	document.querySelector<HTMLElement>('[href="/new/"]')!.click();
});

ipc.answerMain('new-room', async () => {
	document.querySelector<HTMLElement>('.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();
});

ipc.answerMain('log-out', async () => {
	const useWorkChat = await ipc.callMain<undefined, boolean>('get-config-useWorkChat');
	if (useWorkChat) {
		document.querySelector<HTMLElement>('._5lxs._3qct._p')!.click();

		// Menu creation is slow
		setTimeout(() => {
			const nodes = document.querySelectorAll<HTMLElement>(
				'._54nq._9jo._558b._2n_z li:last-child a',
			);

			nodes[nodes.length - 1].click();
		}, 250);
	} else {
		await withSettingsMenu(() => {
			selectMenuItem(-1);
		});
	}
});

ipc.answerMain('find', () => {
	document.querySelector<HTMLElement>('[type="search"]')!.focus();
});

async function openSearchInConversation() {
	const mainView = document.querySelector('.x9f619.x1ja2u2z.x78zum5.x1n2onr6.x1r8uery.x1iyjqo2.xs83m0k.xeuugli.x1qughib.x1qjc9v5.xozqiw3.x1q0g3np.xexx8yu.x85a59c')!;
	const rightSidebarIsClosed = Boolean(mainView.querySelector<HTMLElement>(':scope > div:only-child'));

	if (rightSidebarIsClosed) {
		document.querySelector<HTMLElement>(selectors.rightSidebarMenu)?.click();
	}

	await elementReady(selectors.rightSidebarButtons, {stopOnDomReady: false});
	const buttonList = document.querySelectorAll<HTMLElement>(selectors.rightSidebarButtons);

	// Search in conversation is the last button
	buttonList[buttonList.length - 1].click();
}

ipc.answerMain('search', () => {
	openSearchInConversation();
});

ipc.answerMain('insert-gif', () => {
	document.querySelector<HTMLElement>('.x1n2onr6.x1iyjqo2.xw2csxc > div:nth-child(3) > span > div')!.click();
});

ipc.answerMain('insert-emoji', async () => {
	document.querySelector<HTMLElement>('.x1n2onr6.x1iyjqo2.xw2csxc > div:nth-child(5) > span > div')!.click();
});

ipc.answerMain('insert-sticker', () => {
	document.querySelector<HTMLElement>('.x1n2onr6.x1iyjqo2.xw2csxc > div:nth-child(2) > span > div')!.click();
});

ipc.answerMain('attach-files', () => {
	document.querySelector<HTMLElement>('.x1n2onr6.x1iyjqo2.xw2csxc > div:nth-child(1) > span > div')!.click();
});

ipc.answerMain('focus-text-input', () => {
	document.querySelector<HTMLElement>('[role=textbox][contenteditable=true]')!.focus();
});

ipc.answerMain('next-conversation', nextConversation);

ipc.answerMain('previous-conversation', previousConversation);

ipc.answerMain('mute-conversation', async () => {
	await openMuteModal();
});

ipc.answerMain('delete-conversation', async () => {
	const index = selectedConversationIndex();

	if (index !== -1) {
		await deleteSelectedConversation();

		const key = index + 1;
		await jumpToConversation(key);
	}
});

ipc.answerMain('archive-conversation', async () => {
	const index = selectedConversationIndex();

	if (index !== -1) {
		await archiveSelectedConversation();

		const key = index + 1;
		await jumpToConversation(key);
	}
});

async function openHiddenPreferences(): Promise<boolean> {
	if (!isPreferencesOpen()) {
		document.documentElement.classList.add('hide-preferences-window');

		await openPreferences();

		return true;
	}

	return false;
}

async function toggleSounds({checked}: IToggleSounds): Promise<void> {
	const shouldClosePreferences = await openHiddenPreferences();

	const soundsCheckbox = document.querySelector<HTMLInputElement>(`${selectors.preferencesSelector} ${selectors.messengerSoundsSelector}`)!;
	if (checked === undefined || checked !== soundsCheckbox.checked) {
		soundsCheckbox.click();
	}

	if (shouldClosePreferences) {
		await closePreferences();
	}
}

ipc.answerMain('toggle-sounds', toggleSounds);

ipc.answerMain('toggle-mute-notifications', async () => {
	const shouldClosePreferences = await openHiddenPreferences();

	const notificationCheckbox = document.querySelector<HTMLInputElement>(
		selectors.notificationCheckbox,
	)!;

	if (shouldClosePreferences) {
		await closePreferences();
	}

	// TODO: Fix notifications
	if (notificationCheckbox === null) {
		return false;
	}

	return !notificationCheckbox.checked;
});

ipc.answerMain('toggle-message-buttons', async () => {
	const showMessageButtons = await ipc.callMain<undefined, boolean>('get-config-showMessageButtons');
	document.body.classList.toggle('show-message-buttons', !showMessageButtons);
});

ipc.answerMain('show-chats-view', async () => {
	await selectOtherListViews(1);
});

ipc.answerMain('show-marketplace-view', async () => {
	await selectOtherListViews(2);
});

ipc.answerMain('show-requests-view', async () => {
	await selectOtherListViews(3);
});

ipc.answerMain('show-archive-view', async () => {
	await selectOtherListViews(4);
});

ipc.answerMain('toggle-video-autoplay', () => {
	toggleVideoAutoplay();
});

ipc.answerMain('reload', () => {
	location.reload();
});

async function setTheme(): Promise<void> {
	type ThemeSource = typeof nativeTheme.themeSource;
	const theme = await ipc.callMain<undefined, ThemeSource>('get-config-theme');
	nativeTheme.themeSource = theme;
	setThemeElement(document.documentElement);
	updateVibrancy();
}

function setThemeElement(element: HTMLElement): void {
	const useDarkColors = Boolean(nativeTheme.shouldUseDarkColors);
	element.classList.toggle('dark-mode', useDarkColors);
	element.classList.toggle('light-mode', !useDarkColors);
	element.classList.toggle('__fb-dark-mode', useDarkColors);
	element.classList.toggle('__fb-light-mode', !useDarkColors);
	removeThemeClasses(useDarkColors);
}

function removeThemeClasses(useDarkColors: boolean): void {
	// TODO: Workaround for Facebooks buggy frontend
	// The ui sometimes hardcodes ligth mode classes in the ui. This removes them so the class
	// in the root element would be used.
	const className = useDarkColors ? '__fb-light-mode' : '__fb-dark-mode';
	for (const element of document.querySelectorAll(`.${className}`)) {
		element.classList.remove(className);
	}
}

async function observeTheme(): Promise<void> {
	/* Main document's class list */
	const observer = new MutationObserver((records: MutationRecord[]) => {
		// Find records that had class attribute changed
		const classRecords = records.filter(record => record.type === 'attributes' && record.attributeName === 'class');
		// Check if dark mode classes exists
		const isDark = classRecords.some(record => {
			const {classList} = (record.target as HTMLElement);
			return classList.contains('dark-mode') && classList.contains('__fb-dark-mode');
		});
		// If config and class list don't match, update class list
		if (nativeTheme.shouldUseDarkColors !== isDark) {
			setTheme();
		}
	});

	observer.observe(document.documentElement, {attributes: true, attributeFilter: ['class']});

	/* Added nodes (dialogs, etc.) */
	const observerNew = new MutationObserver((records: MutationRecord[]) => {
		const nodeRecords = records.filter(record => record.addedNodes.length > 0);
		for (const nodeRecord of nodeRecords) {
			for (const newNode of nodeRecord.addedNodes) {
				const {classList} = (newNode as HTMLElement);
				const isLight = classList.contains('light-mode') || classList.contains('__fb-light-mode');
				if (nativeTheme.shouldUseDarkColors === isLight) {
					setThemeElement(newNode as HTMLElement);
				}
			}
		}
	});

	/* Observe only elements where new nodes may need dark mode */
	const menuElements = await elementReady('.j83agx80.cbu4d94t.l9j0dhe7.jgljxmt5.be9z9djy > div:nth-of-type(2) > div', {stopOnDomReady: false});
	if (menuElements) {
		observerNew.observe(menuElements, {childList: true});
	}

	const modalElements = await elementReady(selectors.preferencesSelector, {stopOnDomReady: false});
	if (modalElements) {
		observerNew.observe(modalElements, {childList: true});
	}
}

async function setPrivateMode(): Promise<void> {
	const privateMode = await ipc.callMain<undefined, boolean>('get-config-privateMode');
	document.documentElement.classList.toggle('private-mode', privateMode);

	if (is.macos) {
		sendConversationList();
	}
}

async function updateVibrancy(): Promise<void> {
	const {classList} = document.documentElement;

	classList.remove('sidebar-vibrancy', 'full-vibrancy');

	const vibrancy = await ipc.callMain<undefined, 'sidebar' | 'none' | 'full'>('get-config-vibrancy');

	switch (vibrancy) {
		case 'sidebar': {
			classList.add('sidebar-vibrancy');
			break;
		}

		case 'full': {
			classList.add('full-vibrancy');
			break;
		}

		default:
	}

	ipc.callMain('set-vibrancy');
}

async function updateSidebar(): Promise<void> {
	const {classList} = document.documentElement;

	classList.remove('sidebar-hidden', 'sidebar-force-narrow', 'sidebar-force-wide');

	const sidebar = await ipc.callMain<undefined, 'default' | 'hidden' | 'narrow' | 'wide'>('get-config-sidebar');

	switch (sidebar) {
		case 'hidden': {
			classList.add('sidebar-hidden');
			break;
		}

		case 'narrow': {
			classList.add('sidebar-force-narrow');
			break;
		}

		case 'wide': {
			classList.add('sidebar-force-wide');
			break;
		}

		default:
	}
}

async function updateDoNotDisturb(): Promise<void> {
	/* TODO: Implement this function
	const shouldClosePreferences = await openHiddenPreferences();

	if (shouldClosePreferences) {
		await closePreferences();
	}
	*/
}

function renderOverlayIcon(messageCount: number): HTMLCanvasElement {
	const canvas = document.createElement('canvas');
	canvas.height = 128;
	canvas.width = 128;
	canvas.style.letterSpacing = '-5px';

	const context = canvas.getContext('2d')!;
	context.fillStyle = '#f42020';
	context.beginPath();
	context.ellipse(64, 64, 64, 64, 0, 0, 2 * Math.PI);
	context.fill();
	context.textAlign = 'center';
	context.fillStyle = 'white';
	context.font = '90px sans-serif';
	context.fillText(String(Math.min(99, messageCount)), 64, 96);

	return canvas;
}

ipc.answerMain('update-sidebar', () => {
	updateSidebar();
});

ipc.answerMain('set-theme', setTheme);

ipc.answerMain('set-private-mode', setPrivateMode);

ipc.answerMain('update-vibrancy', () => {
	updateVibrancy();
});

ipc.answerMain('render-overlay-icon', (messageCount: number): {data: string; text: string} => ({
	data: renderOverlayIcon(messageCount).toDataURL(),
	text: String(messageCount),
}));

ipc.answerMain('render-native-emoji', (emoji: string): string => {
	const canvas = document.createElement('canvas');
	const context = canvas.getContext('2d')!;
	const systemFont = is.linux ? 'emoji, system-ui' : 'system-ui';
	canvas.width = 256;
	canvas.height = 256;
	context.textAlign = 'center';
	context.textBaseline = 'middle';
	if (is.macos) {
		context.font = `256px ${systemFont}`;
		context.fillText(emoji, 128, 154);
	} else {
		context.textBaseline = 'bottom';
		context.font = `225px ${systemFont}`;
		context.fillText(emoji, 128, 256);
	}

	const dataUrl = canvas.toDataURL();
	return dataUrl;
});

ipc.answerMain('zoom-reset', async () => {
	await setZoom(1);
});

ipc.answerMain('zoom-in', async () => {
	let zoomFactor = await ipc.callMain<undefined, number>('get-config-zoomFactor');
	zoomFactor += 0.1;

	if (zoomFactor < 1.6) {
		await setZoom(zoomFactor);
	}
});

ipc.answerMain('zoom-out', async () => {
	let zoomFactor = await ipc.callMain<undefined, number>('get-config-zoomFactor');
	zoomFactor -= 0.1;

	if (zoomFactor >= 0.8) {
		await setZoom(zoomFactor);
	}
});

ipc.answerMain('jump-to-conversation', async (key: number) => {
	await jumpToConversation(key);
});

async function nextConversation(): Promise<void> {
	const index = selectedConversationIndex(1);

	if (index !== -1) {
		await selectConversation(index);
	}
}

async function previousConversation(): Promise<void> {
	const index = selectedConversationIndex(-1);

	if (index !== -1) {
		await selectConversation(index);
	}
}

async function jumpToConversation(key: number): Promise<void> {
	const index = key - 1;
	await selectConversation(index);
}

// Focus on the conversation with the given index
async function selectConversation(index: number): Promise<void> {
	const list = await elementReady(selectors.conversationList, {stopOnDomReady: false});

	if (!list) {
		console.error('Could not find conversations list', selectors.conversationList);
		return;
	}

	const conversation = list.children[index];

	if (!conversation) {
		console.error('Could not find conversation', index);
		return;
	}

	conversation.querySelector<HTMLLegendElement>('[role=link]')!.click();
}

function selectedConversationIndex(offset = 0): number {
	const selected = document.querySelector<HTMLElement>(selectors.selectedConversation);

	if (!selected) {
		return -1;
	}

	const newSelected = selected.closest(`${selectors.conversationList} > div`)!;

	const list = [...newSelected.parentNode!.children];
	const index = list.indexOf(newSelected) + offset;

	return ((index % list.length) + list.length) % list.length;
}

async function setZoom(zoomFactor: number): Promise<void> {
	const node = document.querySelector<HTMLElement>('#zoomFactor')!;
	node.textContent = `${selectors.conversationSelector} {zoom: ${zoomFactor} !important}`;
	await ipc.callMain<number, void>('set-config-zoomFactor', zoomFactor);
}

async function withConversationMenu(callback: () => void): Promise<void> {
	// eslint-disable-next-line @typescript-eslint/ban-types
	let menuButton: HTMLElement | null = null;
	const conversation = document.querySelector<HTMLElement>(selectors.selectedConversation)!.closest(`${selectors.conversationList} > div`);

	menuButton = conversation?.querySelector('[aria-label=Menu][role=button]') ?? null;

	if (menuButton) {
		await withMenu(menuButton, callback);
	}
}

async function openMuteModal(): Promise<void> {
	await withConversationMenu(() => {
		selectMenuItem(2);
	});
}

/*
These functions assume:
- There is a selected conversation.
- That the conversation already has its conversation menu open.

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.
*/
function isSelectedConversationGroup(): boolean {
	// Individual conversations include an entry for "View Profile", which is type `a`
	return !document.querySelector<HTMLElement>(`${selectors.conversationMenuSelectorNewDesign} a[role=menuitem]`);
}

function isSelectedConversationMetaAI(): boolean {
	// Meta AI menu only has 1 separator of type `hr`
	return !document.querySelector<HTMLElement>(`${selectors.conversationMenuSelectorNewDesign} hr:nth-of-type(2)`);
}

async function archiveSelectedConversation(): Promise<void> {
	await withConversationMenu(() => {
		const [isGroup, isNotGroup, isMetaAI] = [-4, -3, -2];

		let archiveMenuIndex;
		if (isSelectedConversationMetaAI()) {
			archiveMenuIndex = isMetaAI;
		} else if (isSelectedConversationGroup()) {
			archiveMenuIndex = isGroup;
		} else {
			archiveMenuIndex = isNotGroup;
		}

		selectMenuItem(archiveMenuIndex);
	});
}

async function deleteSelectedConversation(): Promise<void> {
	await withConversationMenu(() => {
		const [isGroup, isNotGroup, isMetaAI] = [-3, -2, -1];

		let deleteMenuIndex;
		if (isSelectedConversationMetaAI()) {
			deleteMenuIndex = isMetaAI;
		} else if (isSelectedConversationGroup()) {
			deleteMenuIndex = isGroup;
		} else {
			deleteMenuIndex = isNotGroup;
		}

		selectMenuItem(deleteMenuIndex);
	});
}

async function openPreferences(): Promise<void> {
	await withSettingsMenu(() => {
		selectMenuItem(1);
	});

	await elementReady(selectors.preferencesSelector, {stopOnDomReady: false});
}

function isPreferencesOpen(): boolean {
	return Boolean(document.querySelector<HTMLElement>(selectors.preferencesSelector));
}

async function closePreferences(): Promise<void> {
	// Wait for the preferences window to be closed, then remove the class from the document
	const preferencesOverlayObserver = new MutationObserver(records => {
		const removedRecords = records.filter(({removedNodes}) => removedNodes.length > 0 && (removedNodes[0] as HTMLElement).tagName === 'DIV');

		// In case there is a div removed, hide utility class and stop observing
		if (removedRecords.length > 0) {
			document.documentElement.classList.remove('hide-preferences-window');
			preferencesOverlayObserver.disconnect();
		}
	});

	const preferencesOverlay = document.querySelector(selectors.preferencesSelector)!;

	// Get the parent of preferences, that's not getting deleted
	const preferencesParent = preferencesOverlay.closest('div:not([class])')!;

	preferencesOverlayObserver.observe(preferencesParent, {childList: true});

	const closeButton = preferencesOverlay.querySelector(selectors.closePreferencesButton)!;
	(closeButton as HTMLElement)?.click();
}

function insertionListener(event: AnimationEvent): void {
	if (event.animationName === 'nodeInserted' && event.target) {
		event.target.dispatchEvent(new Event('mouseover', {bubbles: true}));
	}
}

async function observeAutoscroll(): Promise<void> {
	const mainElement = await elementReady('._4sp8', {stopOnDomReady: false});
	if (!mainElement) {
		return;
	}

	const scrollToBottom = (): void => {
		// eslint-disable-next-line @typescript-eslint/ban-types
		const scrollableElement: HTMLElement | null = document.querySelector('[role=presentation] .scrollable');
		if (scrollableElement) {
			scrollableElement.scroll({
				top: Number.MAX_SAFE_INTEGER,
				behavior: 'smooth',
			});
		}
	};

	const hookMessageObserver = async (): Promise<void> => {
		const chatElement = await elementReady(
			'[role=presentation] .scrollable [role = region] > div[id ^= "js_"]', {stopOnDomReady: false},
		);

		if (chatElement) {
			// Scroll to the bottom when opening different conversation
			scrollToBottom();

			const messageObserver = new MutationObserver((record: MutationRecord[]) => {
				const newMessages: MutationRecord[] = record.filter(record =>
					// The mutation is an addition
					record.addedNodes.length > 0
						// ... of a div       (skip the "seen" status change)
						&& (record.addedNodes[0] as HTMLElement).tagName === 'DIV'
						// ... on the last child       (skip previous messages added when scrolling up)
						&& chatElement.lastChild!.contains(record.target),
				);

				if (newMessages.length > 0) {
					// Scroll to the bottom when there are new messages
					scrollToBottom();
				}
			});

			messageObserver.observe(chatElement, {childList: true, subtree: true});
		}
	};

	hookMessageObserver();

	// Hook it again if conversation changes
	const conversationObserver = new MutationObserver(hookMessageObserver);
	conversationObserver.observe(mainElement, {childList: true});
}

async function observeThemeBugs(): Promise<void> {
	const rootObserver = new MutationObserver((record: MutationRecord[]) => {
		const newNodes: MutationRecord[] = record
			.filter(record => record.addedNodes.length > 0 || record.removedNodes.length > 0);

		if (newNodes) {
			removeThemeClasses(Boolean(nativeTheme.shouldUseDarkColors));
		}
	});

	rootObserver.observe(document.documentElement, {childList: true, subtree: true});
}

// Listen for emoji element dom insertion
document.addEventListener('animationstart', insertionListener, false);

// Inject a global style node to maintain custom appearance after conversation change or startup
document.addEventListener('DOMContentLoaded', async () => {
	const style = document.createElement('style');
	style.id = 'zoomFactor';
	document.body.append(style);

	// Set the zoom factor if it was set before quitting
	const zoomFactor = await ipc.callMain<undefined, number>('get-config-zoomFactor');
	setZoom(zoomFactor);

	// Enable OS specific styles
	document.documentElement.classList.add(`os-${process.platform}`);

	// Restore sidebar view state to what is was set before quitting
	updateSidebar();

	// Activate Dark Mode if it was set before quitting
	setTheme();
	// Observe for dark mode changes
	observeTheme();

	// Activate Private Mode if it was set before quitting
	setPrivateMode();

	// Configure do not disturb
	if (is.macos) {
		await updateDoNotDisturb();
	}

	// Prevent flash of white on startup when in dark mode
	// TODO: find a CSS-only solution
	if (!is.macos && nativeTheme.shouldUseDarkColors) {
		document.documentElement.style.backgroundColor = '#1e1e1e';
	}

	// Disable autoplay if set in settings
	toggleVideoAutoplay();

	// Hook auto-scroll observer
	observeAutoscroll();

	// Hook broken dark mode observer
	observeThemeBugs();
});

// Handle title bar double-click.
window.addEventListener('dblclick', (event: Event) => {
	const target = event.target as HTMLElement;
	const titleBar = target.closest('._36ic._5l-3,._5742,._6-xk,._673w');

	if (!titleBar) {
		return;
	}

	ipc.callMain('titlebar-doubleclick');
}, {
	passive: true,
});

window.addEventListener('load', async () => {
	if (location.pathname.startsWith('/login')) {
		const keepMeSignedInCheckbox = document.querySelector<HTMLInputElement>('[id^="u_0_0"]')!;
		const keepMeSignedInConfig = await ipc.callMain<undefined, boolean>('get-config-keepMeSignedIn');
		keepMeSignedInCheckbox.checked = keepMeSignedInConfig;
		keepMeSignedInCheckbox.addEventListener('change', async () => {
			const keepMeSignedIn = await ipc.callMain<undefined, boolean>('get-config-keepMeSignedIn');
			await ipc.callMain('set-config-keepMeSignedIn', keepMeSignedIn);
		});
	}
});

// Toggles styles for inactive window
window.addEventListener('blur', () => {
	document.documentElement.classList.add('is-window-inactive');
});
window.addEventListener('focus', () => {
	document.documentElement.classList.remove('is-window-inactive');
});

// It's not possible to add multiple accelerators
// so this needs to be done the old-school way
document.addEventListener('keydown', async event => {
	// The `!event.altKey` part is a workaround for https://github.com/electron/electron/issues/13895
	const combineKey = is.macos ? event.metaKey : event.ctrlKey && !event.altKey;

	if (!combineKey) {
		return;
	}

	if (event.key === ']') {
		await nextConversation();
	}

	if (event.key === '[') {
		await previousConversation();
	}

	const number = Number.parseInt(event.code.slice(-1), 10);

	if (number >= 1 && number <= 9) {
		await jumpToConversation(number);
	}
});

// Pass events sent via `window.postMessage` on to the main process
window.addEventListener('message', async ({data: {type, data}}) => {
	if (type === 'notification') {
		showNotification(data as NotificationEvent);
	}

	if (type === 'notification-reply') {
		await sendReply(data.reply as string);

		if (data.previousConversation) {
			await selectConversation(data.previousConversation as number);
		}
	}
});

function showNotification({id, title, body, icon, silent}: NotificationEvent): void {
	const image = new Image();
	image.crossOrigin = 'anonymous';
	image.src = icon;

	image.addEventListener('load', () => {
		const canvas = document.createElement('canvas');
		const context = canvas.getContext('2d')!;

		canvas.width = image.width;
		canvas.height = image.height;

		context.drawImage(image, 0, 0, image.width, image.height);

		ipc.callMain('notification', {
			id,
			title,
			body,
			icon: canvas.toDataURL(),
			silent,
		});
	});
}

async function sendReply(message: string): Promise<void> {
	const inputField = document.querySelector<HTMLElement>('[contenteditable="true"]');
	if (!inputField) {
		return;
	}

	const previousMessage = inputField.textContent;

	// Send message
	inputField.focus();
	insertMessageText(message, inputField);

	const sendButton = await elementReady<HTMLElement>('._30yy._38lh', {stopOnDomReady: false});
	if (!sendButton) {
		console.error('Could not find send button');
		return;
	}

	sendButton.click();

	// Restore (possible) previous message
	if (previousMessage) {
		insertMessageText(previousMessage, inputField);
	}
}

function insertMessageText(text: string, inputField: HTMLElement): void {
	// Workaround: insert placeholder value to get execCommand working
	if (!inputField.textContent) {
		const event = new InputEvent('textInput', {
			bubbles: true,
			cancelable: true,
			data: '_',
			view: window,
		});
		inputField.dispatchEvent(event);
	}

	document.execCommand('selectAll', false, undefined);
	document.execCommand('insertText', false, text);
}

ipc.answerMain('notification-callback', (data: unknown) => {
	window.postMessage({type: 'notification-callback', data}, '*');
});

ipc.answerMain('notification-reply-callback', async (data: any) => {
	const previousConversation = selectedConversationIndex();
	data.previousConversation = previousConversation;
	window.postMessage({type: 'notification-reply-callback', data}, '*');
});


================================================
FILE: source/config.ts
================================================
import Store from 'electron-store';
import {is} from 'electron-util';
import {EmojiStyle} from './emoji';

export type StoreType = {
	theme: 'system' | 'light' | 'dark';
	privateMode: boolean;
	showPrivateModePrompt: boolean;
	vibrancy: 'none' | 'sidebar' | 'full';
	zoomFactor: number;
	lastWindowState: {
		x: number;
		y: number;
		width: number;
		height: number;
		isMaximized: boolean;
	};
	menuBarMode: boolean;
	showDockIcon: boolean;
	showTrayIcon: boolean;
	alwaysOnTop: boolean;
	showAlwaysOnTopPrompt: boolean;
	bounceDockOnMessage: boolean;
	showUnreadBadge: boolean;
	showMessageButtons: boolean;
	launchMinimized: boolean;
	flashWindowOnMessage: boolean;
	notificationMessagePreview: boolean;
	block: {
		chatSeen: boolean;
		typingIndicator: boolean;
		deliveryReceipt: boolean;
	};
	emojiStyle: EmojiStyle;
	useWorkChat: boolean;
	sidebar: 'default' | 'hidden' | 'narrow' | 'wide';
	autoHideMenuBar: boolean;
	autoUpdate: boolean;
	notificationsMuted: boolean;
	callRingtoneMuted: boolean;
	hardwareAcceleration: boolean;
	quitOnWindowClose: boolean;
	keepMeSignedIn: boolean;
	autoplayVideos: boolean;
	isSpellCheckerEnabled: boolean;
	spellCheckerLanguages: string[];
};

const schema: Store.Schema<StoreType> = {
	theme: {
		type: 'string',
		enum: ['system', 'light', 'dark'],
		default: 'system',
	},
	privateMode: {
		type: 'boolean',
		default: false,
	},
	showPrivateModePrompt: {
		type: 'boolean',
		default: true,
	},
	vibrancy: {
		type: 'string',
		enum: ['none', 'sidebar', 'full'],
		// TODO: Change the default to 'sidebar' when the vibrancy issue in Electron is fixed.
		// See https://github.com/electron/electron/issues/10420
		default: 'none',
	},
	zoomFactor: {
		type: 'number',
		default: 1,
	},
	lastWindowState: {
		type: 'object',
		properties: {
			x: {
				type: 'number',
			},
			y: {
				type: 'number',
			},
			width: {
				type: 'number',
			},
			height: {
				type: 'number',
			},
			isMaximized: {
				type: 'boolean',
			},
		},
		default: {
			x: undefined,
			y: undefined,
			width: 800,
			height: 600,
			isMaximized: false,
		},
	},
	menuBarMode: {
		type: 'boolean',
		default: false,
	},
	showDockIcon: {
		type: 'boolean',
		default: true,
	},
	showTrayIcon: {
		type: 'boolean',
		default: true,
	},
	alwaysOnTop: {
		type: 'boolean',
		default: false,
	},
	showAlwaysOnTopPrompt: {
		type: 'boolean',
		default: true,
	},
	bounceDockOnMessage: {
		type: 'boolean',
		default: false,
	},
	showUnreadBadge: {
		type: 'boolean',
		default: true,
	},
	showMessageButtons: {
		type: 'boolean',
		default: true,
	},
	launchMinimized: {
		type: 'boolean',
		default: false,
	},
	flashWindowOnMessage: {
		type: 'boolean',
		default: true,
	},
	notificationMessagePreview: {
		type: 'boolean',
		default: true,
	},
	block: {
		type: 'object',
		properties: {
			chatSeen: {
				type: 'boolean',
			},
			typingIndicator: {
				type: 'boolean',
			},
			deliveryReceipt: {
				type: 'boolean',
			},
		},
		default: {
			chatSeen: false,
			typingIndicator: false,
			deliveryReceipt: false,
		},
	},
	emojiStyle: {
		type: 'string',
		enum: ['native', 'facebook-3-0', 'messenger-1-0', 'facebook-2-2'],
		default: 'facebook-3-0',
	},
	useWorkChat: {
		type: 'boolean',
		default: false,
	},
	sidebar: {
		type: 'string',
		enum: ['default', 'hidden', 'narrow', 'wide'],
		default: 'default',
	},
	autoHideMenuBar: {
		type: 'boolean',
		default: false,
	},
	autoUpdate: {
		type: 'boolean',
		default: true,
	},
	notificationsMuted: {
		type: 'boolean',
		default: false,
	},
	callRingtoneMuted: {
		type: 'boolean',
		default: false,
	},
	hardwareAcceleration: {
		type: 'boolean',
		default: true,
	},
	quitOnWindowClose: {
		type: 'boolean',
		default: false,
	},
	keepMeSignedIn: {
		type: 'boolean',
		default: true,
	},
	autoplayVideos: {
		type: 'boolean',
		default: true,
	},
	isSpellCheckerEnabled: {
		type: 'boolean',
		default: true,
	},
	spellCheckerLanguages: {
		type: 'array',
		items: {
			type: 'string',
		},
		default: [],
	},
};

function updateVibrancySetting(store: Store<StoreType>): void {
	const vibrancy = store.get('vibrancy');

	if (!is.macos || !vibrancy) {
		store.set('vibrancy', 'none');
	// @ts-expect-error
	} else if (vibrancy === true) {
		store.set('vibrancy', 'full');
	// @ts-expect-error
	} else if (vibrancy === false) {
		store.set('vibrancy', 'sidebar');
	}
}

function updateSidebarSetting(store: Store<StoreType>): void {
	if (store.get('sidebarHidden')) {
		store.set('sidebar', 'hidden');
		// @ts-expect-error
		store.delete('sidebarHidden');
	} else if (!store.has('sidebar')) {
		store.set('sidebar', 'default');
	}
}

function updateThemeSetting(store: Store<StoreType>): void {
	const darkMode = store.get('darkMode');
	const followSystemAppearance = store.get('followSystemAppearance');

	if (is.macos && followSystemAppearance) {
		store.set('theme', 'system');
	} else if (darkMode !== undefined) {
		store.set('theme', darkMode ? 'dark' : 'light');
	} else if (!store.has('theme')) {
		store.set('theme', 'system');
	}

	if (darkMode !== undefined) {
		// @ts-expect-error
		store.delete('darkMode');
	}

	if (followSystemAppearance !== undefined) {
		// @ts-expect-error
		store.delete('followSystemAppearance');
	}
}

function migrate(store: Store<StoreType>): void {
	updateVibrancySetting(store);
	updateSidebarSetting(store);
	updateThemeSetting(store);
}

const store = new Store<StoreType>({schema});
migrate(store);

export default store;


================================================
FILE: source/constants.ts
================================================
import * as path from 'node:path';
import {fixPathForAsarUnpack} from 'electron-util';

export const caprineIconPath = fixPathForAsarUnpack(path.join(__dirname, '..', 'static', 'Icon.png'));


================================================
FILE: source/conversation.d.ts
================================================
type Conversation = {
	label: string;
	selected: boolean;
	unread: boolean;
	icon: string;
};


================================================
FILE: source/do-not-disturb.d.ts
================================================
declare module '@sindresorhus/do-not-disturb';


================================================
FILE: source/emoji.ts
================================================
import * as path from 'node:path';
import {
	nativeImage,
	NativeImage,
	MenuItemConstructorOptions,
	CallbackResponse,
	Menu,
} from 'electron';
import {is} from 'electron-util';
import {memoize} from 'lodash';
import {showRestartDialog, getWindow, sendBackgroundAction} from './util';
import config from './config';

// The list of emojis that aren't supported by older emoji (facebook-2-2, messenger-1-0)
// Based on https://emojipedia.org/facebook/3.0/new/
const excludedEmoji = new Set([
	'f0000', // Facebook's thumbs-up icon as shown on the contacts list
	'1f3c3_200d_2640',
	'1f3c4_200d_2640',
	'1f3ca_200d_2640',
	'1f3f4_200d_2620',
	'1f468_1f3fb_200d_1f9b0',
	'1f468_1f3fb_200d_1f9b1',
	'1f468_1f3fb_200d_1f9b2',
	'1f468_1f3fb_200d_1f9b3',
	'1f468_1f3fc_200d_1f9b0',
	'1f468_1f3fc_200d_1f9b1',
	'1f468_1f3fc_200d_1f9b2',
	'1f468_1f3fc_200d_1f9b3',
	'1f468_1f3fd_200d_1f9b0',
	'1f468_1f3fd_200d_1f9b1',
	'1f468_1f3fd_200d_1f9b2',
	'1f468_1f3fd_200d_1f9b3',
	'1f468_1f3fe_200d_1f9b0',
	'1f468_1f3fe_200d_1f9b1',
	'1f468_1f3fe_200d_1f9b2',
	'1f468_1f3fe_200d_1f9b3',
	'1f468_1f3ff_200d_1f9b0',
	'1f468_1f3ff_200d_1f9b1',
	'1f468_1f3ff_200d_1f9b2',
	'1f468_1f3ff_200d_1f9b3',
	'1f468_200d_1f9b0',
	'1f468_200d_1f9b1',
	'1f468_200d_1f9b2',
	'1f468_200d_1f9b3',
	'1f468_200d_2764_200d_1f468',
	'1f468_200d_2764_200d_1f48b_200d_1f468',
	'1f469_1f3fb_200d_1f9b0',
	'1f469_1f3fb_200d_1f9b1',
	'1f469_1f3fb_200d_1f9b2',
	'1f469_1f3fb_200d_1f9b3',
	'1f469_1f3fc_200d_1f9b0',
	'1f469_1f3fc_200d_1f9b1',
	'1f469_1f3fc_200d_1f9b2',
	'1f469_1f3fc_200d_1f9b3',
	'1f469_1f3fd_200d_1f9b0',
	'1f469_1f3fd_200d_1f9b1',
	'1f469_1f3fd_200d_1f9b2',
	'1f469_1f3fd_200d_1f9b3',
	'1f469_1f3fe_200d_1f9b0',
	'1f469_1f3fe_200d_1f9b1',
	'1f469_1f3fe_200d_1f9b2',
	'1f469_1f3fe_200d_1f9b3',
	'1f469_1f3ff_200d_1f9b0',
	'1f469_1f3ff_200d_1f9b1',
	'1f469_1f3ff_200d_1f9b2',
	'1f469_1f3ff_200d_1f9b3',
	'1f469_200d_1f9b0',
	'1f469_200d_1f9b1',
	'1f469_200d_1f9b2',
	'1f469_200d_1f9b3',
	'1f469_200d_2764_200d_1f469',
	'1f469_200d_2764_200d_1f48b_200d_1f469',
	'1f46e_200d_2640',
	'1f46f_200d_2640',
	'1f471_200d_2640',
	'1f473_200d_2640',
	'1f477_200d_2640',
	'1f481_200d_2640',
	'1f482_200d_2640',
	'1f486_200d_2640',
	'1f487_200d_2640',
	'1f645_200d_2640',
	'1f646_200d_2640',
	'1f647_200d_2640',
	'1f64b_200d_2640',
	'1f64d_200d_2640',
	'1f64e_200d_2640',
	'1f6a3_200d_2640',
	'1f6b4_200d_2640',
	'1f6b5_200d_2640',
	'1f6b6_200d_2640',
	'1f6f9',
	'1f94d',
	'1f94e',
	'1f94f',
	'1f96c',
	'1f96d',
	'1f96e',
	'1f96f',
	'1f970',
	'1f973',
	'1f974',
	'1f975',
	'1f976',
	'1f97a',
	'1f97c',
	'1f97d',
	'1f97e',
	'1f97f',
	'1f998',
	'1f999',
	'1f99a',
	'1f99b',
	'1f99c',
	'1f99d',
	'1f99e',
	'1f99f',
	'1f9a0',
	'1f9a1',
	'1f9a2',
	'1f9b0',
	'1f9b1',
	'1f9b2',
	'1f9b3',
	'1f9b4',
	'1f9b5_1f3fb',
	'1f9b5_1f3fc',
	'1f9b5_1f3fd',
	'1f9b5_1f3fe',
	'1f9b5_1f3ff',
	'1f9b5',
	'1f9b6_1f3fb',
	'1f9b6_1f3fc',
	'1f9b6_1f3fd',
	'1f9b6_1f3fe',
	'1f9b6_1f3ff',
	'1f9b6',
	'1f9b7',
	'1f9b8_1f3fb',
	'1f9b8_1f3fb_200d_2640',
	'1f9b8_1f3fb_200d_2642',
	'1f9b8_1f3fc',
	'1f9b8_1f3fc_200d_2640',
	'1f9b8_1f3fc_200d_2642',
	'1f9b8_1f3fd',
	'1f9b8_1f3fd_200d_2640',
	'1f9b8_1f3fd_200d_2642',
	'1f9b8_1f3fe',
	'1f9b8_1f3fe_200d_2640',
	'1f9b8_1f3fe_200d_2642',
	'1f9b8_1f3ff',
	'1f9b8_1f3ff_200d_2640',
	'1f9b8_1f3ff_200d_2642',
	'1f9b8_200d_2640',
	'1f9b8_200d_2642',
	'1f9b8',
	'1f9b9_1f3fb',
	'1f9b9_1f3fb_200d_2640',
	'1f9b9_1f3fb_200d_2642',
	'1f9b9_1f3fc',
	'1f9b9_1f3fc_200d_2640',
	'1f9b9_1f3fc_200d_2642',
	'1f9b9_1f3fd',
	'1f9b9_1f3fd_200d_2640',
	'1f9b9_1f3fd_200d_2642',
	'1f9b9_1f3fe',
	'1f9b9_1f3fe_200d_2640',
	'1f9b9_1f3fe_200d_2642',
	'1f9b9_1f3ff',
	'1f9b9_1f3ff_200d_2640',
	'1f9b9_1f3ff_200d_2642',
	'1f9b9_200d_2640',
	'1f9b9_200d_2642',
	'1f9b9',
	'1f9c1',
	'1f9c2',
	'1f9e7',
	'1f9e8',
	'1f9e9',
	'1f9ea',
	'1f9eb',
	'1f9ec',
	'1f9ed',
	'1f9ee',
	'1f9ef',
	'1f9f0',
	'1f9f1',
	'1f9f2',
	'1f9f3',
	'1f9f4',
	'1f9f5',
	'1f9f6',
	'1f9f7',
	'1f9f8',
	'1f9f9',
	'1f9fa',
	'1f9fb',
	'1f9fc',
	'1f9fd',
	'1f9fe',
	'1f9ff',
	'265f',
	'267e',
]);

export enum EmojiStyle {
	Native = 'native',
	Facebook30 = 'facebook-3-0',
	Messenger10 = 'messenger-1-0',
	Facebook22 = 'facebook-2-2',
}

enum EmojiStyleCode {
	Facebook30 = 't',
	Messenger10 = 'z',
	Facebook22 = 'f',
}

function codeForEmojiStyle(style: EmojiStyle): EmojiStyleCode {
	switch (style) {
		case 'facebook-2-2': {
			return EmojiStyleCode.Facebook22;
		}

		case 'messenger-1-0': {
			return EmojiStyleCode.Messenger10;
		}

		default: {
			return EmojiStyleCode.Facebook30;
		}
	}
}

/**
Renders the given emoji in the renderer process and returns a PNG `data:` URL.
*/
const renderEmoji = memoize(async (emoji: string): Promise<string> => sendBackgroundAction<string, string>('render-native-emoji', emoji));

/**
@param url - A Facebook emoji URL like `https://static.xx.fbcdn.net/images/emoji.php/v9/tae/2/16/1f471_1f3fb_200d_2640.png`.
*/
function urlToEmoji(url: string): string {
	const codePoints = url
		.split('/')
		.pop()!
		.replace(/\.png$/, '')
		.split('_')
		.map(hexCodePoint => Number.parseInt(hexCodePoint, 16));

	// F0000 (983040 decimal) is Facebook's thumbs-up icon
	if (codePoints.length === 1 && codePoints[0] === 983_040) {
		return '👍';
	}

	// Emoji is missing Variation Selector-16 (\uFE0F):
	// "An invisible codepoint which specifies that the preceding character
	// should be displayed with emoji presentation.
	// Only required if the preceding character defaults to text presentation."
	return String.fromCodePoint(...codePoints) + '\uFE0F';
}

const cachedEmojiMenuIcons = new Map<EmojiStyle, NativeImage>();

/**
@returns An icon to use for the menu item of this emoji style.
*/
async function getEmojiIcon(style: EmojiStyle): Promise<NativeImage | undefined> {
	const cachedIcon = cachedEmojiMenuIcons.get(style);

	if (cachedIcon) {
		return cachedIcon;
	}

	if (style === 'native') {
		if (!getWindow()) {
			return undefined;
		}

		const dataUrl = await renderEmoji('🙂');
		const image = nativeImage.createFromDataURL(dataUrl);
		const resizedImage = image.resize({width: 16, height: 16});

		cachedEmojiMenuIcons.set(style, resizedImage);

		return resizedImage;
	}

	const image = nativeImage.createFromPath(
		path.join(__dirname, '..', 'static', `emoji-${style}.png`),
	);

	cachedEmojiMenuIcons.set(style, image);

	return image;
}

/**
For example, when 'emojiStyle' setting is set to 'messenger-1-0' it replaces
this URL:  https://static.xx.fbcdn.net/images/emoji.php/v9/t27/2/32/1f600.png
with this: https://static.xx.fbcdn.net/images/emoji.php/v9/z27/2/32/1f600.png
                                                 (see here) ^
*/
export async function process(url: string): Promise<CallbackResponse> {
	const emojiStyle = config.get('emojiStyle');
	const emojiSetCode = codeForEmojiStyle(emojiStyle);

	// The character code is the filename without the extension.
	const characterCodeEnd = url.lastIndexOf('.png');
	const characterCode = url.slice(url.lastIndexOf('/') + 1, characterCodeEnd);

	if (emojiStyle === EmojiStyle.Native) {
		const emoji = urlToEmoji(url);
		const dataUrl = await renderEmoji(emoji);
		return {redirectURL: dataUrl};
	}

	if (
		// Don't replace emoji from Facebook's latest emoji set
		emojiSetCode === 't'
		// Don't replace the same URL in a loop
		|| url.includes('#replaced')
		// Ignore non-png files
		|| characterCodeEnd === -1
		// Messenger 1.0 and Facebook 2.2 emoji sets support only emoji up to version 5.0.
		// Fall back to default style for emoji >= 10.0
		|| excludedEmoji.has(characterCode)
	) {
		return {};
	}

	const emojiSetPrefix = 'emoji.php/v9/';
	const emojiSetIndex = url.indexOf(emojiSetPrefix) + emojiSetPrefix.length;
	const newURL
		= url.slice(0, emojiSetIndex) + emojiSetCode + url.slice(emojiSetIndex + 1) + '#replaced';

	return {redirectURL: newURL};
}

export async function generateSubmenu(
	updateMenu: () => Promise<Menu>,
): Promise<MenuItemConstructorOptions[]> {
	const emojiMenuOption = async (
		label: string,
		style: EmojiStyle,
		visibility: boolean,
	): Promise<MenuItemConstructorOptions> => ({
		label,
		type: 'checkbox',
		visible: visibility,
		icon: await getEmojiIcon(style),
		checked: config.get('emojiStyle') === style,
		async click() {
			if (config.get('emojiStyle') === style) {
				return;
			}

			config.set('emojiStyle', style);

			await updateMenu();
			showRestartDialog('Caprine needs to be restarted to apply emoji changes.');
		},
	});

	return Promise.all([
		emojiMenuOption('System', EmojiStyle.Native, true),
		{type: 'separator'} as const,
		emojiMenuOption('Facebook 3.0', EmojiStyle.Facebook30, true),
		emojiMenuOption('Messenger 1.0', EmojiStyle.Messenger10, !is.linux || is.development),
		emojiMenuOption('Facebook 2.2', EmojiStyle.Facebook22, true),
	]);
}


================================================
FILE: source/ensure-online.ts
================================================
import {app, dialog} from 'electron';
import isOnline from 'is-online';
import pWaitFor from 'p-wait-for';

function showWaitDialog(): void {
	const buttonIndex = dialog.showMessageBoxSync({
		message: 'You appear to be offline. Caprine requires a working internet connection.',
		detail: 'Do you want to wait?',
		buttons: [
			'Wait',
			'Quit',
		],
		defaultId: 0,
		cancelId: 1,
	});

	if (buttonIndex === 1) {
		app.quit();
	}
}

export default async (): Promise<void> => {
	if (!(await isOnline())) {
		const connectivityTimeout = setTimeout(showWaitDialog, 15_000);

		await pWaitFor(isOnline, {interval: 1000});
		clearTimeout(connectivityTimeout);
	}
};


================================================
FILE: source/index.ts
================================================
import path from 'node:path';
import {readFileSync, existsSync} from 'node:fs';
import {
	app,
	nativeImage,
	screen as electronScreen,
	session,
	shell,
	BrowserWindow,
	Menu,
	Notification,
	MenuItemConstructorOptions,
	systemPreferences,
	nativeTheme,
} from 'electron';
import {ipcMain as ipc} from 'electron-better-ipc';
import {autoUpdater} from 'electron-updater';
import electronDl from 'electron-dl';
import electronContextMenu from 'electron-context-menu';
import electronLocalshortcut from 'electron-localshortcut';
import electronDebug from 'electron-debug';
import {is, darkMode} from 'electron-util';
import {bestFacebookLocaleFor} from 'facebook-locales';
import doNotDisturb from '@sindresorhus/do-not-disturb';
import updateAppMenu from './menu';
import config, {StoreType} from './config';
import tray from './tray';
import {
	sendAction,
	sendBackgroundAction,
	messengerDomain,
	stripTrackingFromUrl,
} from './util';
import {process as processEmojiUrl} from './emoji';
import ensureOnline from './ensure-online';
import {setUpMenuBarMode} from './menu-bar-mode';
import {caprineIconPath} from './constants';

ipc.setMaxListeners(100);

electronDebug({
	isEnabled: true, // TODO: This is only enabled to allow `Command+R` because messenger.com sometimes gets stuck after computer waking up
	showDevTools: false,
});

electronDl();
electronContextMenu({
	showCopyImageAddress: true,
	prepend(defaultActions) {
		/*
		TODO: Use menu option or use replacement of options (https://github.com/sindresorhus/electron-context-menu/issues/70)
		See explanation for this hacky solution here: https://github.com/sindresorhus/caprine/pull/1169
		*/
		defaultActions.copyLink({
			transform: stripTrackingFromUrl,
		});

		return [];
	},
});

app.setAppUserModelId('com.sindresorhus.caprine');

if (!config.get('hardwareAcceleration')) {
	app.disableHardwareAcceleration();
}

if (!is.development && config.get('autoUpdate')) {
	(async () => {
		const FOUR_HOURS = 1000 * 60 * 60 * 4;
		setInterval(async () => {
			await autoUpdater.checkForUpdatesAndNotify();
		}, FOUR_HOURS);

		await autoUpdater.checkForUpdatesAndNotify();
	})();
}

let mainWindow: BrowserWindow;
let isQuitting = false;
let previousMessageCount = 0;
let dockMenu: Menu;
let isDNDEnabled = false;

if (!app.requestSingleInstanceLock()) {
	app.quit();
}

app.on('second-instance', () => {
	if (mainWindow) {
		if (mainWindow.isMinimized()) {
			mainWindow.restore();
		}

		mainWindow.show();
	}
});

// Preserves the window position when a display is removed and Caprine is moved to a different screen.
app.on('ready', () => {
	electronScreen.on('display-removed', () => {
		const [x, y] = mainWindow.getPosition();
		mainWindow.setPosition(x, y);
	});
});

async function updateBadge(messageCount: number): Promise<void> {
	if (!is.windows) {
		if (config.get('showUnreadBadge') && !isDNDEnabled) {
			app.badgeCount = messageCount;
		}

		if (
			is.macos
			&& !isDNDEnabled
			&& config.get('bounceDockOnMessage')
			&& previousMessageCount !== messageCount
		) {
			app.dock.bounce('informational');
			previousMessageCount = messageCount;
		}
	}

	if (!is.macos) {
		if (config.get('showUnreadBadge')) {
			tray.setBadge(messageCount > 0);
		}

		if (config.get('flashWindowOnMessage')) {
			mainWindow.flashFrame(messageCount !== 0);
		}
	}

	tray.update(messageCount);

	if (is.windows) {
		if (!config.get('showUnreadBadge') || messageCount === 0) {
			mainWindow.setOverlayIcon(null, '');
		} else {
			// Delegate drawing of overlay icon to renderer process
			updateOverlayIcon(await ipc.callRenderer(mainWindow, 'render-overlay-icon', messageCount));
		}
	}
}

function updateOverlayIcon({data, text}: {data: string; text: string}): void {
	const img = nativeImage.createFromDataURL(data);
	mainWindow.setOverlayIcon(img, text);
}

type BeforeSendHeadersResponse = {
	cancel?: boolean;
	requestHeaders?: Record<string, string>;
};

type OnSendHeadersDetails = {
	id: number;
	url: string;
	method: string;
	webContentsId?: number;
	resourceType: string;
	referrer: string;
	timestamp: number;
	requestHeaders: Record<string, string>;
};

function enableHiresResources(): void {
	const scaleFactor = Math.max(
		...electronScreen.getAllDisplays().map(display => display.scaleFactor),
	);

	if (scaleFactor === 1) {
		return;
	}

	const filter = {urls: [`*://*.${messengerDomain}/`]};

	session.defaultSession.webRequest.onBeforeSendHeaders(
		filter,
		(details: OnSendHeadersDetails, callback: (response: BeforeSendHeadersResponse) => void) => {
			let cookie = details.requestHeaders.Cookie;

			if (cookie && details.method === 'GET') {
				cookie = /(?:; )?dpr=\d/.test(cookie) ? cookie.replace(/dpr=\d/, `dpr=${scaleFactor}`) : `${cookie}; dpr=${scaleFactor}`;

				(details.requestHeaders as any).Cookie = cookie;
			}

			callback({
				cancel: false,
				requestHeaders: details.requestHeaders,
			});
		},
	);
}

function initRequestsFiltering(): void {
	const filter = {
		urls: [
			`*://*.${messengerDomain}/*typ.php*`, // Type indicator blocker
			`*://*.${messengerDomain}/*change_read_status.php*`, // Seen indicator blocker
			`*://*.${messengerDomain}/*delivery_receipts*`, // Delivery receipts indicator blocker
			`*://*.${messengerDomain}/*unread_threads*`, // Delivery receipts indicator blocker
			'*://*.fbcdn.net/images/emoji.php/v9/*', // Emoji
			'*://*.facebook.com/images/emoji.php/v9/*', // Emoji
		],
	};

	session.defaultSession.webRequest.onBeforeRequest(filter, async ({url}, callback) => {
		if (url.includes('emoji.php')) {
			callback(await processEmojiUrl(url));
		} else if (url.includes('typ.php')) {
			callback({cancel: config.get('block.typingIndicator' as any)});
		} else if (url.includes('change_read_status.php')) {
			callback({cancel: config.get('block.chatSeen' as any)});
		} else if (url.includes('delivery_receipts') || url.includes('unread_threads')) {
			callback({cancel: config.get('block.deliveryReceipt' as any)});
		}
	});

	session.defaultSession.webRequest.onHeadersReceived({
		urls: ['*://static.xx.fbcdn.net/rsrc.php/*'],
	}, ({responseHeaders}, callback) => {
		if (!config.get('callRingtoneMuted') || !responseHeaders) {
			callback({});
			return;
		}

		const callRingtoneHash = '2NAu/QVqg211BbktgY5GkA==';
		callback({
			cancel: responseHeaders['content-md5'][0] === callRingtoneHash,
		});
	});
}

function setUserLocale(): void {
	const userLocale = bestFacebookLocaleFor(app.getLocale().replace('-', '_'));
	const cookie = {
		url: 'https://www.messenger.com/',
		name: 'locale',
		secure: true,
		value: userLocale,
	};

	session.defaultSession.cookies.set(cookie);
}

function setNotificationsMute(status: boolean): void {
	const label = 'Mute Notifications';
	const muteMenuItem = Menu.getApplicationMenu()!.getMenuItemById('mute-notifications')!;

	config.set('notificationsMuted', status);
	muteMenuItem.checked = status;

	if (is.macos) {
		const item = dockMenu.items.find(x => x.label === label);
		item!.checked = status;
	}
}

function createMainWindow(): BrowserWindow {
	const lastWindowState = config.get('lastWindowState');

	// Messenger or Work Chat
	const mainURL = config.get('useWorkChat')
		? 'https://work.facebook.com/chat'
		: 'https://www.messenger.com/login/';

	const win = new BrowserWindow({
		title: app.name,
		show: false,
		x: lastWindowState.x,
		y: lastWindowState.y,
		width: lastWindowState.width,
		height: lastWindowState.height,
		icon: is.linux ? caprineIconPath : undefined,
		minWidth: 400,
		minHeight: 200,
		alwaysOnTop: config.get('alwaysOnTop'),
		titleBarStyle: 'hiddenInset',
		trafficLightPosition: {
			x: 80,
			y: 20,
		},
		autoHideMenuBar: config.get('autoHideMenuBar'),
		webPreferences: {
			preload: path.join(__dirname, 'browser.js'),
			contextIsolation: true,
			nodeIntegration: true,
			spellcheck: config.get('isSpellCheckerEnabled'),
			plugins: true,
		},
	});

	require('@electron/remote/main').initialize();
	require('@electron/remote/main').enable(win.webContents);

	setUserLocale();
	initRequestsFiltering();

	let previousDarkMode = darkMode.isEnabled;
	darkMode.onChange(() => {
		if (darkMode.isEnabled !== previousDarkMode) {
			previousDarkMode = darkMode.isEnabled;
			win.webContents.send('set-theme');
		}
	});

	if (is.macos) {
		win.setSheetOffset(40);
	}

	win.loadURL(mainURL);

	win.on('close', event => {
		if (config.get('quitOnWindowClose')) {
			app.quit();
			return;
		}

		// Workaround for https://github.com/electron/electron/issues/20263
		// Closing the app window when on full screen leaves a black screen
		// Exit fullscreen before closing
		if (is.macos && mainWindow.isFullScreen()) {
			mainWindow.once('leave-full-screen', () => {
				mainWindow.hide();
			});
			mainWindow.setFullScreen(false);
		}

		if (!isQuitting) {
			event.preventDefault();

			// Workaround for https://github.com/electron/electron/issues/10023
			win.blur();
			if (is.macos) {
				// On macOS we're using `app.hide()` in order to focus the previous window correctly
				app.hide();
			} else {
				win.hide();
			}
		}
	});

	win.on('focus', () => {
		if (config.get('flashWindowOnMessage')) {
			// This is a security in the case where messageCount is not reset by page title update
			win.flashFrame(false);
		}
	});

	win.on('resize', () => {
		const {isMaximized} = config.get('lastWindowState');
		config.set('lastWindowState', {...win.getNormalBounds(), isMaximized});
	});

	win.on('maximize', () => {
		config.set('lastWindowState.isMaximized', true);
	});

	win.on('unmaximize', () => {
		config.set('lastWindowState.isMaximized', false);
	});

	return win;
}

(async () => {
	await Promise.all([ensureOnline(), app.whenReady()]);
	await updateAppMenu();
	mainWindow = createMainWindow();

	// Workaround for https://github.com/electron/electron/issues/5256
	electronLocalshortcut.register(mainWindow, 'CommandOrControl+=', () => {
		sendAction('zoom-in');
	});

	// Start in menu bar mode if enabled, otherwise start normally
	setUpMenuBarMode(mainWindow);

	if (is.macos) {
		const firstItem: MenuItemConstructorOptions = {
			label: 'Mute Notifications',
			type: 'checkbox',
			visible: is.development,
			checked: config.get('notificationsMuted'),
			async click() {
				setNotificationsMute(await ipc.callRenderer(mainWindow, 'toggle-mute-notifications'));
			},
		};

		dockMenu = Menu.buildFromTemplate([firstItem]);
		app.dock.setMenu(dockMenu);

		// Dock icon is hidden initially on macOS
		if (config.get('showDockIcon')) {
			app.dock.show();
		}

		ipc.once('conversations', () => {
			// Messenger sorts the conversations by unread state.
			// We select the first conversation from the list.
			sendAction('jump-to-conversation', 1);
		});

		ipc.answerRenderer('conversations', (conversations: Conversation[]) => {
			if (conversations.length === 0) {
				return;
			}

			const items = conversations.map(({label, icon}, index) => ({
				label: `${label}`,
				icon: nativeImage.createFromDataURL(icon),
				click() {
					mainWindow.show();
					sendAction('jump-to-conversation', index + 1);
				},
			}));

			app.dock.setMenu(Menu.buildFromTemplate([firstItem, {type: 'separator'}, ...items]));
		});
	}

	// Update badge on conversations change
	ipc.answerRenderer('update-tray-icon', async (messageCount: number) => {
		updateBadge(messageCount);
	});

	enableHiresResources();

	const {webContents} = mainWindow;

	webContents.on('dom-ready', async () => {
		// Set window title to Caprine
		mainWindow.setTitle(app.name);

		await updateAppMenu();

		const files = ['browser.css', 'dark-mode.css', 'vibrancy.css', 'code-blocks.css', 'autoplay.css', 'scrollbar.css'];

		const cssPath = path.join(__dirname, '..', 'css');

		for (const file of files) {
			if (existsSync(path.join(cssPath, file))) {
				webContents.insertCSS(readFileSync(path.join(cssPath, file), 'utf8'));
			}
		}

		if (config.get('useWorkChat') && existsSync(path.join(cssPath, 'workchat.css'))) {
			webContents.insertCSS(
				readFileSync(path.join(cssPath, 'workchat.css'), 'utf8'),
			);
		}

		if (existsSync(path.join(app.getPath('userData'), 'custom.css'))) {
			webContents.insertCSS(readFileSync(path.join(app.getPath('userData'), 'custom.css'), 'utf8'));
		}

		if (config.get('launchMinimized') || app.getLoginItemSettings().wasOpenedAsHidden) {
			mainWindow.hide();
			tray.create(mainWindow);
		} else {
			if (config.get('lastWindowState').isMaximized) {
				mainWindow.maximize();
			}

			mainWindow.show();
		}

		if (is.macos) {
			// TODO: 'update-dnd-mode' is not called
			ipc.answerRenderer('update-dnd-mode', async (initialSoundsValue: boolean) => {
				doNotDisturb.on('change', (doNotDisturb: boolean) => {
					isDNDEnabled = doNotDisturb;
					ipc.callRenderer(mainWindow, 'toggle-sounds', {checked: isDNDEnabled ? false : initialSoundsValue});
				});

				isDNDEnabled = await doNotDisturb.isEnabled();

				return isDNDEnabled ? false : initialSoundsValue;
			});
		}

		// TODO: Re-enable this when muting notifications is fixed
		// setNotificationsMute(await ipc.callRenderer(mainWindow, 'toggle-mute-notifications', {
		// 	defaultStatus: config.get('notificationsMuted'),
		// }));

		ipc.callRenderer(mainWindow, 'toggle-message-buttons', config.get('showMessageButtons'));

		await webContents.executeJavaScript(
			readFileSync(path.join(__dirname, 'notifications-isolated.js'), 'utf8'),
		);

		if (is.macos) {
			await import('./touch-bar');
		}
	});

	webContents.setWindowOpenHandler(details => {
		if (details.disposition === 'foreground-tab' || details.disposition === 'background-tab') {
			const url = stripTrackingFromUrl(details.url);
			shell.openExternal(url);
			return {action: 'deny'};
		}

		if (details.disposition === 'new-window') {
			if (details.url === 'about:blank' || details.url === 'about:blank#blocked') {
				if (details.frameName !== 'about:blank') {
					// Voice/video call popup
					return {
						action: 'allow',
						overrideBrowserWindowOptions: {
							show: true,
							titleBarStyle: 'default',
							webPreferences: {
								nodeIntegration: false,
								preload: path.join(__dirname, 'browser-call.js'),
							},
						},
					};
				}
			} else {
				const url = stripTrackingFromUrl(details.url);
				shell.openExternal(url);
			}

			return {action: 'deny'};
		}

		return {action: 'allow'};
	});

	webContents.on('will-navigate', async (event, url) => {
		const isMessengerDotCom = (url: string): boolean => {
			const {hostname} = new URL(url);
			return hostname.endsWith('.messenger.com');
		};

		const isTwoFactorAuth = (url: string): boolean => {
			const twoFactorAuthURL = 'https://www.facebook.com/checkpoint';
			return url.startsWith(twoFactorAuthURL);
		};

		const isWorkChat = (url: string): boolean => {
			const {hostname, pathname} = new URL(url);

			if (hostname === 'work.facebook.com' || hostname === 'work.workplace.com') {
				return true;
			}

			if (
				// Example: https://company-name.facebook.com/login or
				//   		https://company-name.workplace.com/login
				(hostname.endsWith('.facebook.com') || hostname.endsWith('.workplace.com'))
				&& (pathname.startsWith('/login') || pathname.startsWith('/chat'))
			) {
				return true;
			}

			if (hostname === 'login.microsoftonline.com') {
				return true;
			}

			return false;
		};

		if (isMessengerDotCom(url) || isTwoFactorAuth(url) || isWorkChat(url)) {
			return;
		}

		event.preventDefault();
		await shell.openExternal(url);
	});
})();

if (is.macos) {
	ipc.answerRenderer('set-vibrancy', () => {
		mainWindow.setBackgroundColor('#80FFFFFF'); // Transparent, workaround for vibrancy issue.
		mainWindow.setVibrancy('sidebar');
	});
}

function toggleMaximized(): void {
	if (mainWindow.isMaximized()) {
		mainWindow.unmaximize();
	} else {
		mainWindow.maximize();
	}
}

ipc.answerRenderer('titlebar-doubleclick', () => {
	if (is.macos) {
		const doubleClickAction = systemPreferences.getUserDefault('AppleActionOnDoubleClick', 'string');

		if (doubleClickAction === 'Minimize') {
			mainWindow.minimize();
		} else if (doubleClickAction === 'Maximize') {
			toggleMaximized();
		}
	} else {
		toggleMaximized();
	}
});

app.on('activate', () => {
	if (mainWindow) {
		mainWindow.show();
	}
});

app.on('before-quit', () => {
	isQuitting = true;

	// Checking whether the window exists to work around an Electron race issue:
	// https://github.com/sindresorhus/caprine/issues/809
	if (mainWindow) {
		const {isMaximized} = config.get('lastWindowState');
		config.set('lastWindowState', {...mainWindow.getNormalBounds(), isMaximized});
	}
});

const notifications = new Map();

ipc.answerRenderer(
	'notification',
	({id, title, body, icon, silent}: {id: number; title: string; body: string; icon: string; silent: boolean}) => {
		// Don't send notifications when the window is focused
		if (mainWindow.isFocused()) {
			return;
		}

		const notification = new Notification({
			title,
			body: config.get('notificationMessagePreview') ? body : 'You have a new message',
			hasReply: true,
			icon: nativeImage.createFromDataURL(icon),
			silent,
		});

		notifications.set(id, notification);

		notification.on('click', () => {
			sendAction('notification-callback', {callbackName: 'onclick', id});

			notifications.delete(id);
		});

		notification.on('reply', (_event, reply: string) => {
			// We use onclick event used by messenger to go to the right convo
			sendBackgroundAction('notification-reply-callback', {callbackName: 'onclick', id, reply});

			notifications.delete(id);
		});

		notification.on('close', () => {
			sendBackgroundAction('notification-callback', {callbackName: 'onclose', id});
			notifications.delete(id);
		});

		notification.show();
	},
);

type ThemeSource = typeof nativeTheme.themeSource;

ipc.answerRenderer<undefined, StoreType['useWorkChat']>('get-config-useWorkChat', async () => config.get('useWorkChat'));
ipc.answerRenderer<undefined, StoreType['showMessageButtons']>('get-config-showMessageButtons', async () => config.get('showMessageButtons'));
ipc.answerRenderer<undefined, ThemeSource>('get-config-theme', async () => config.get('theme'));
ipc.answerRenderer<undefined, StoreType['privateMode']>('get-config-privateMode', async () => config.get('privateMode'));
ipc.answerRenderer<undefined, StoreType['vibrancy']>('get-config-vibrancy', async () => config.get('vibrancy'));
ipc.answerRenderer<undefined, StoreType['sidebar']>('get-config-sidebar', async () => config.get('sidebar'));
ipc.answerRenderer<undefined, StoreType['zoomFactor']>('get-config-zoomFactor', async () => config.get('zoomFactor'));
ipc.answerRenderer<StoreType['zoomFactor'], void>('set-config-zoomFactor', async zoomFactor => {
	config.set('zoomFactor', zoomFactor);
});
ipc.answerRenderer<undefined, StoreType['keepMeSignedIn']>('get-config-keepMeSignedIn', async () => config.get('keepMeSignedIn'));
ipc.answerRenderer<StoreType['keepMeSignedIn'], void>('set-config-keepMeSignedIn', async keepMeSignedIn => {
	config.set('keepMeSignedIn', keepMeSignedIn);
});
ipc.answerRenderer<undefined, StoreType['autoplayVideos']>('get-config-autoplayVideos', async () => config.get('autoplayVideos'));
ipc.answerRenderer<undefined, StoreType['emojiStyle']>('get-config-emojiStyle', async () => config.get('emojiStyle'));
ipc.answerRenderer<StoreType['emojiStyle'], void>('set-config-emojiStyle', async emojiStyle => {
	config.set('emojiStyle', emojiStyle);
});


================================================
FILE: source/menu-bar-mode.ts
================================================
import {
	app,
	globalShortcut,
	BrowserWindow,
	Menu,
} from 'electron';
import {is} from 'electron-util';
import config from './config';
import tray from './tray';

const menuBarShortcut = 'Command+Shift+y';

export function toggleMenuBarMode(window: BrowserWindow): void {
	const isEnabled = config.get('menuBarMode');
	const menuItem = Menu.getApplicationMenu()!.getMenuItemById('menuBarMode')!;

	menuItem.checked = isEnabled;

	window.setVisibleOnAllWorkspaces(isEnabled);

	if (isEnabled) {
		globalShortcut.register(menuBarShortcut, () => {
			if (window.isVisible()) {
				window.hide();
			} else {
				window.show();
			}
		});

		tray.create(window);
	} else {
		globalShortcut.unregister(menuBarShortcut);

		tray.destroy();
		app.dock.show();
		window.show();
	}
}

export function setUpMenuBarMode(window: BrowserWindow): void {
	if (is.macos) {
		toggleMenuBarMode(window);
	} else if (config.get('showTrayIcon') && !config.get('quitOnWindowClose')) {
		tray.create(window);
	}
}


================================================
FILE: source/menu.ts
================================================
import * as path from 'node:path';
import {existsSync, writeFileSync} from 'node:fs';
import {
	app,
	shell,
	Menu,
	MenuItemConstructorOptions,
	dialog,
} from 'electron';
import {
	is,
	appMenu,
	openUrlMenuItem,
	aboutMenuItem,
	openNewGitHubIssue,
	debugInfo,
} from 'electron-util';
import config from './config';
import getSpellCheckerLanguages from './spell-checker';
import {
	sendAction,
	showRestartDialog,
	getWindow,
	toggleTrayIcon,
	toggleLaunchMinimized,
} from './util';
import {generateSubmenu as generateEmojiSubmenu} from './emoji';
import {toggleMenuBarMode} from './menu-bar-mode';
import {caprineIconPath} from './constants';

export default async function updateMenu(): Promise<Menu> {
	const newConversationItem: MenuItemConstructorOptions = {
		label: 'New Conversation',
		accelerator: 'CommandOrControl+N',
		click() {
			sendAction('new-conversation');
		},
	};

	const newRoomItem: MenuItemConstructorOptions = {
		label: 'New Room',
		accelerator: 'CommandOrControl+O',
		click() {
			sendAction('new-room');
		},
	};

	const switchItems: MenuItemConstructorOptions[] = [
		{
			label: 'Switch to Work Chat…',
			accelerator: 'CommandOrControl+Shift+2',
			visible: !config.get('useWorkChat'),
			click() {
				config.set('useWorkChat', true);
				app.relaunch();
				app.quit();
			},
		},
		{
			label: 'Switch to Messenger…',
			accelerator: 'CommandOrControl+Shift+1',
			visible: config.get('useWorkChat'),
			click() {
				config.set('useWorkChat', false);
				app.relaunch();
				app.quit();
			},
		},
		{
			label: 'Log Out',
			click() {
				sendAction('log-out');
			},
		},
	];

	const vibrancySubmenu: MenuItemConstructorOptions[] = [
		{
			label: 'No Vibrancy',
			type: 'checkbox',
			checked: config.get('vibrancy') === 'none',
			async click() {
				config.set('vibrancy', 'none');
				sendAction('update-vibrancy');
				await updateMenu();
			},
		},
		{
			label: 'Sidebar-only Vibrancy',
			type: 'checkbox',
			checked: config.get('vibrancy') === 'sidebar',
			async click() {
				config.set('vibrancy', 'sidebar');
				sendAction('update-vibrancy');
				await updateMenu();
			},
		},
		{
			label: 'Full-window Vibrancy',
			type: 'checkbox',
			checked: config.get('vibrancy') === 'full',
			async click() {
				config.set('vibrancy', 'full');
				sendAction('update-vibrancy');
				await updateMenu();
			},
		},
	];

	const themeSubmenu: MenuItemConstructorOptions[] = [
		{
			label: 'Follow System Appearance',
			type: 'checkbox',
			checked: config.get('theme') === 'system',
			async click() {
				config.set('theme', 'system');
				sendAction('set-theme');
				await updateMenu();
			},
		},
		{
			label: 'Light Mode',
			type: 'checkbox',
			checked: config.get('theme') === 'light',
			async click() {
				config.set('theme', 'light');
				sendAction('set-theme');
				await updateMenu();
			},
		},
		{
			label: 'Dark Mode',
			type: 'checkbox',
			checked: config.get('theme') === 'dark',
			async click() {
				config.set('theme', 'dark');
				sendAction('set-theme');
				await updateMenu();
			},
		},
	];

	const sidebarSubmenu: MenuItemConstructorOptions[] = [
		{
			label: 'Adaptive Sidebar',
			type: 'checkbox',
			checked: config.get('sidebar') === 'default',
			async click() {
				config.set('sidebar', 'default');
				sendAction('update-sidebar');
				await updateMenu();
			},
		},
		{
			label: 'Hide Sidebar',
			type: 'checkbox',
			checked: config.get('sidebar') === 'hidden',
			accelerator: 'CommandOrControl+Shift+S',
			async click() {
				// Toggle between default and hidden
				config.set('sidebar', config.get('sidebar') === 'hidden' ? 'default' : 'hidden');
				sendAction('update-sidebar');
				await updateMenu();
			},
		},
		{
			label: 'Narrow Sidebar',
			type: 'checkbox',
			checked: config.get('sidebar') === 'narrow',
			async click() {
				config.set('sidebar', 'narrow');
				sendAction('update-sidebar');
				await updateMenu();
			},
		},
		{
			label: 'Wide Sidebar',
			type: 'checkbox',
			checked: config.get('sidebar') === 'wide',
			async click() {
				config.set('sidebar', 'wide');
				sendAction('update-sidebar');
				await updateMenu();
			},
		},
	];

	const privacySubmenu: MenuItemConstructorOptions[] = [
		{
			label: 'Block Seen Indicator',
			type: 'checkbox',
			checked: config.get('block.chatSeen' as any),
			click(menuItem) {
				config.set('block.chatSeen' as any, menuItem.checked);
			},
		},
		{
			label: 'Block Typing Indicator',
			type: 'checkbox',
			checked: config.get('block.typingIndicator' as any),
			click(menuItem) {
				config.set('block.typingIndicator' as any, menuItem.checked);
			},
		},
		{
			label: 'Block Delivery Receipts',
			type: 'checkbox',
			checked: config.get('block.deliveryReceipt' as any),
			click(menuItem) {
				config.set('block.deliveryReceipt' as any, menuItem.checked);
			},
		},
	];

	const advancedSubmenu: MenuItemConstructorOptions[] = [
		{
			label: 'Custom Styles',
			click() {
				const filePath = path.join(app.getPath('userData'), 'custom.css');
				const defaultCustomStyle = `/*
This is the custom styles file where you can add anything you want.
The styles here will be injected into Caprine and will override default styles.
If you want to disable styles but keep the config, just comment the lines that you don't want to be used.

Press Command/Ctrl+R in Caprine to see your changes.
*/
`;

				if (!existsSync(filePath)) {
					writeFileSync(filePath, defaultCustomStyle, 'utf8');
				}

				shell.openPath(filePath);
			},
		},
	];

	const preferencesSubmenu: MenuItemConstructorOptions[] = [
		{
			/* TODO: Fix privacy features */
			/* If you want to help, see #1688 */
			label: 'Privacy',
			visible: is.development,
			submenu: privacySubmenu,
		},
		{
			label: 'Emoji Style',
			submenu: await generateEmojiSubmenu(updateMenu),
		},
		{
			label: 'Bounce Dock on Message',
			type: 'checkbox',
			visible: is.macos,
			checked: config.get('bounceDockOnMessage'),
			click() {
				config.set('bounceDockOnMessage', !config.get('bounceDockOnMessage'));
			},
		},
		{
			/* TODO: Fix ability to disable autoplay */
			/* GitHub issue: #1845 */
			label: 'Autoplay Videos',
			id: 'video-autoplay',
			type: 'checkbox',
			visible: is.development,
			checked: config.get('autoplayVideos'),
			click() {
				config.set('autoplayVideos', !config.get('autoplayVideos'));
				sendAction('toggle-video-autoplay');
			},
		},
		{
			/* TODO: Fix notifications */
			label: 'Show Message Preview in Notifications',
			type: 'checkbox',
			visible: is.development,
			checked: config.get('notificationMessagePreview'),
			click(menuItem) {
				config.set('notificationMessagePreview', menuItem.checked);
			},
		},
		{
			/* TODO: Fix notifications */
			label: 'Mute Notifications',
			id: 'mute-notifications',
			type: 'checkbox',
			visible: is.development,
			checked: config.get('notificationsMuted'),
			click() {
				sendAction('toggle-mute-notifications');
			},
		},
		{
			label: 'Mute Call Ringtone',
			type: 'checkbox',
			checked: config.get('callRingtoneMuted'),
			click() {
				config.set('callRingtoneMuted', !config.get('callRingtoneMuted'));
			},
		},
		{
			/* TODO: Fix notification badge */
			label: 'Show Unread Badge',
			type: 'checkbox',
			visible: is.development,
			checked: config.get('showUnreadBadge'),
			click() {
				config.set('showUnreadBadge', !config.get('showUnreadBadge'));
				sendAction('reload');
			},
		},
		{
			label: 'Spell Checker',
			type: 'checkbox',
			checked: config.get('isSpellCheckerEnabled'),
			click() {
				config.set('isSpellCheckerEnabled', !config.get('isSpellCheckerEnabled'));
				showRestartDialog('Caprine needs to be restarted to enable or disable the spell checker.');
			},
		},
		{
			label: 'Hardware Acceleration',
			type: 'checkbox',
			checked: config.get('hardwareAcceleration'),
			click() {
				config.set('hardwareAcceleration', !config.get('hardwareAcceleration'));
				showRestartDialog('Caprine needs to be restarted to change hardware acceleration.');
			},
		},
		{
			label: 'Show Menu Bar Icon',
			id: 'menuBarMode',
			type: 'checkbox',
			visible: is.macos,
			checked: config.get('menuBarMode'),
			click() {
				config.set('menuBarMode', !config.get('menuBarMode'));
				toggleMenuBarMode(getWindow());
			},
		},
		{
			label: 'Always on Top',
			id: 'always-on-top',
			type: 'checkbox',
			accelerator: 'CommandOrControl+Shift+T',
			checked: config.get('alwaysOnTop'),
			async click(menuItem, focusedWindow, event) {
				if (!config.get('alwaysOnTop') && config.get('showAlwaysOnTopPrompt') && event.shiftKey) {
					const result = await dialog.showMessageBox(focusedWindow!, {
						message: 'Are you sure you want the window to stay on top of other windows?',
						detail: 'This was triggered by Command/Control+Shift+T.',
						buttons: [
							'Display on Top',
							'Don\'t Display on Top',
						],
						defaultId: 0,
						cancelId: 1,
						checkboxLabel: 'Don\'t ask me again',
					});

					config.set('showAlwaysOnTopPrompt', !result.checkboxChecked);

					if (result.response === 0) {
						config.set('alwaysOnTop', !config.get('alwaysOnTop'));
						focusedWindow?.setAlwaysOnTop(menuItem.checked);
					} else if (result.response === 1) {
						menuItem.checked = false;
					}
				} else {
					config.set('alwaysOnTop', !config.get('alwaysOnTop'));
					focusedWindow?.setAlwaysOnTop(menuItem.checked);
				}
			},
		},
		{
			/* TODO: Add support for Linux */
			label: 'Launch at Login',
			visible: !is.linux,
			type: 'checkbox',
			checked: app.getLoginItemSettings().openAtLogin,
			click(menuItem) {
				app.setLoginItemSettings({
					openAtLogin: menuItem.checked,
					openAsHidden: menuItem.checked,
				});
			},
		},
		{
			label: 'Auto Hide Menu Bar',
			type: 'checkbox',
			visible: !is.macos,
			checked: config.get('autoHideMenuBar'),
			click(menuItem, focusedWindow) {
				config.set('autoHideMenuBar', menuItem.checked);
				focusedWindow?.setAutoHideMenuBar(menuItem.checked);
				focusedWindow?.setMenuBarVisibility(!menuItem.checked);

				if (menuItem.checked) {
					dialog.showMessageBox({
						type: 'info',
						message: 'Press the Alt key to toggle the menu bar.',
						buttons: ['OK'],
					});
				}
			},
		},
		{
			label: 'Automatic Updates',
			type: 'checkbox',
			checked: config.get('autoUpdate'),
			click() {
				config.set('autoUpdate', !config.get('autoUpdate'));
			},
		},
		{
			/* TODO: Fix notifications */
			label: 'Flash Window on Message',
			type: 'checkbox',
			visible: is.development,
			checked: config.get('flashWindowOnMessage'),
			click(menuItem) {
				config.set('flashWindowOnMessage', menuItem.checked);
			},
		},
		{
			id: 'showTrayIcon',
			label: 'Show Tray Icon',
			type: 'checkbox',
			enabled: !is.macos && !config.get('launchMinimized'),
			checked: config.get('showTrayIcon'),
			click() {
				toggleTrayIcon();
			},
		},
		{
			label: 'Launch Minimized',
			type: 'checkbox',
			visible: !is.macos,
			checked: config.get('launchMinimized'),
			click() {
				toggleLaunchMinimized(menu);
			},
		},
		{
			label: 'Quit on Window Close',
			type: 'checkbox',
			checked: config.get('quitOnWindowClose'),
			click() {
				config.set('quitOnWindowClose', !config.get('quitOnWindowClose'));
			},
		},
		{
			type: 'separator',
		},
		{
			label: 'Advanced',
			submenu: advancedSubmenu,
		},
	];

	const viewSubmenu: MenuItemConstructorOptions[] = [
		{
			label: 'Reset Text Size',
			accelerator: 'CommandOrControl+0',
			click() {
				sendAction('zoom-reset');
			},
		},
		{
			label: 'Increase Text Size',
			accelerator: 'CommandOrControl+Plus',
			click() {
				sendAction('zoom-in');
			},
		},
		{
			label: 'Decrease Text Size',
			accelerator: 'CommandOrControl+-',
			click() {
				sendAction('zoom-out');
			},
		},
		{
			type: 'separator',
		},
		{
			label: 'Theme',
			submenu: themeSubmenu,
		},
		{
			label: 'Vibrancy',
			visible: is.macos,
			submenu: vibrancySubmenu,
		},
		{
			type: 'separator',
		},
		{
			label: 'Hide Names and Avatars',
			id: 'privateMode',
			type: 'checkbox',
			checked: config.get('privateMode'),
			accelerator: 'CommandOrControl+Shift+N',
			async click(menuItem, _browserWindow, event) {
				if (!config.get('privateMode') && config.get('showPrivateModePrompt') && event.shiftKey) {
					const result = await dialog.showMessageBox(_browserWindow!, {
						message: 'Are you sure you want to hide names and avatars?',
						detail: 'This was triggered by Command/Control+Shift+N.',
						buttons: [
							'Hide',
							'Don\'t Hide',
						],
						defaultId: 0,
						cancelId: 1,
						checkboxLabel: 'Don\'t ask me again',
					});

					config.set('showPrivateModePrompt', !result.checkboxChecked);

					if (result.response === 0) {
						config.set('privateMode', !config.get('privateMode'));
						sendAction('set-private-mode');
					} else if (result.response === 1) {
						menuItem.checked = false;
					}
				} else {
					config.set('privateMode', !config.get('privateMode'));
					sendAction('set-private-mode');
				}
			},
		},
		{
			type: 'separator',
		},
		{
			label: 'Sidebar',
			submenu: sidebarSubmenu,
		},
		{
			label: 'Show Message Buttons',
			type: 'checkbox',
			checked: config.get('showMessageButtons'),
			click() {
				config.set('showMessageButtons', !config.get('showMessageButtons'));
				sendAction('toggle-message-buttons');
			},
		},
		{
			type: 'separator',
		},
		{
			label: 'Show Main Chats',
			click() {
				sendAction('show-chats-view');
			},
		},
		{
			label: 'Show Marketplace Chats',
			click() {
				sendAction('show-marketplace-view');
			},
		},
		{
			label: 'Show Message Requests',
			click() {
				sendAction('show-requests-view');
			},
		},
		{
			label: 'Show Archived Chats',
			click() {
				sendAction('show-archive-view');
			},
		},
	];

	const spellCheckerSubmenu: MenuItemConstructorOptions[] = getSpellCheckerLanguages();

	const conversationSubmenu: MenuItemConstructorOptions[] = [
		{
			label: 'Mute Conversation',
			accelerator: 'CommandOrControl+Shift+M',
			click() {
				sendAction('mute-conversation');
			},
		},
		{
			label: 'Archive Conversation',
			accelerator: 'CommandOrControl+Shift+H',
			click() {
				sendAction('archive-conversation');
			},
		},
		{
			label: 'Delete Conversation',
			accelerator: 'CommandOrControl+Shift+D',
			click() {
				sendAction('delete-conversation');
			},
		},
		{
			label: 'Select Next Conversation',
			accelerator: 'Control+Tab',
			click() {
				sendAction('next-conversation');
			},
		},
		{
			label: 'Select Previous Conversation',
			accelerator: 'Control+Shift+Tab',
			click() {
				sendAction('previous-conversation');
			},
		},
		{
			label: 'Find Conversation',
			accelerator: 'CommandOrControl+K',
			click() {
				sendAction('find');
			},
		},
		{
			label: 'Search in Conversation',
			accelerator: 'CommandOrControl+F',
			click() {
				sendAction('search');
			},
		},
		{
			label: 'Insert GIF',
			accelerator: 'CommandOrControl+G',
			click() {
				sendAction('insert-gif');
			},
		},
		{
			label: 'Insert Sticker',
			accelerator: 'CommandOrControl+S',
			click() {
				sendAction('insert-sticker');
			},
		},
		{
			label: 'Insert Emoji',
			accelerator: 'CommandOrControl+E',
			click() {
				sendAction('insert-emoji');
			},
		},
		{
			label: 'Attach Files',
			accelerator: 'CommandOrControl+T',
			click() {
				sendAction('attach-files');
			},
		},
		{
			label: 'Focus Text Input',
			accelerator: 'CommandOrControl+I',
			click() {
				sendAction('focus-text-input');
			},
		},
		{
			type: 'separator',
		},
		{
			label: 'Spell Checker Language',
			visible: !is.macos && config.get('isSpellCheckerEnabled'),
			submenu: spellCheckerSubmenu,
		},
	];

	const helpSubmenu: MenuItemConstructorOptions[] = [
		openUrlMenuItem({
			label: 'Website',
			url: 'https://github.com/sindresorhus/caprine',
		}),
		openUrlMenuItem({
			label: 'Source Code',
			url: 'https://github.com/sindresorhus/caprine',
		}),
		openUrlMenuItem({
			label: 'Donate…',
			url: 'https://github.com/sindresorhus/caprine?sponsor=1',
		}),
		{
			label: 'Report an Issue…',
			click() {
				const body = `
<!-- Please succinctly describe your issue and steps to reproduce it. -->


---

${debugInfo()}`;

				openNewGitHubIssue({
					user: 'sindresorhus',
					repo: 'caprine',
					body,
				});
			},
		},
	];

	if (!is.macos) {
		helpSubmenu.push(
			{
				type: 'separator',
			},
			aboutMenuItem({
				icon: caprineIconPath,
				copyright: 'Created by Sindre Sorhus',
				text: 'Maintainers:\nDušan Simić\nLefteris Garyfalakis\nMichael Quevillon\nNikolas Spiridakis',
				website: 'https://github.com/sindresorhus/caprine',
			}),
		);
	}

	const debugSubmenu: MenuItemConstructorOptions[] = [
		{
			label: 'Show Settings',
			click() {
				config.openInEditor();
			},
		},
		{
			label: 'Show App Data',
			click() {
				shell.openPath(app.getPath('userData'));
			},
		},
		{
			type: 'separator',
		},
		{
			label: 'Delete Settings',
			click() {
				config.clear();
				app.relaunch();
				app.quit();
			},
		},
		{
			label: 'Delete App Data',
			click() {
				shell.trashItem(app.getPath('userData'));
				app.relaunch();
				app.quit();
			},
		},
	];

	const macosTemplate: MenuItemConstructorOptions[] = [
		appMenu([
			{
				label: 'Caprine Preferences',
				submenu: preferencesSubmenu,
			},
			{
				label: 'Messenger Preferences…',
				accelerator: 'Command+,',
				click() {
					sendAction('show-preferences');
				},
			},
			{
				type: 'separator',
			},
			...switchItems,
			{
				type: 'separator',
			},
			{
				label: 'Relaunch Caprine',
				click() {
					app.relaunch();
					app.quit();
				},
			},
		]),
		{
			role: 'fileMenu',
			submenu: [
				newConversationItem,
				newRoomItem,
				{
					type: 'separator',
				},
				{
					role: 'close',
				},
			],
		},
		{
			role: 'editMenu',
		},
		{
			role: 'viewMenu',
			submenu: viewSubmenu,
		},
		{
			label: 'Conversation',
			submenu: conversationSubmenu,
		},
		{
			role: 'windowMenu',
		},
		{
			role: 'help',
			submenu: helpSubmenu,
		},
	];

	const linuxWindowsTemplate: MenuItemConstructorOptions[] = [
		{
			role: 'fileMenu',
			submenu: [
				newConversationItem,
				newRoomItem,
				{
					type: 'separator',
				},
				{
					label: 'Caprine Settings',
					submenu: preferencesSubmenu,
				},
				{
					label: 'Messenger Settings',
					accelerator: 'Control+,',
					click() {
						sendAction('show-preferences');
					},
				},
				{
					type: 'separator',
				},
				...switchItems,
				{
					type: 'separator',
				},
				{
					label: 'Relaunch Caprine',
					click() {
						app.relaunch();
						app.quit();
					},
				},
				{
					role: 'quit',
				},
			],
		},
		{
			role: 'editMenu',
		},
		{
			role: 'viewMenu',
			submenu: viewSubmenu,
		},
		{
			label: 'Conversation',
			submenu: conversationSubmenu,
		},
		{
			role: 'help',
			submenu: helpSubmenu,
		},
	];

	const template = is.macos ? macosTemplate : linuxWindowsTemplate;

	if (is.development) {
		template.push({
			label: 'Debug',
			submenu: debugSubmenu,
		});
	}

	const menu = Menu.buildFromTemplate(template);
	Menu.setApplicationMenu(menu);

	return menu;
}


================================================
FILE: source/notification-event.d.ts
================================================
type NotificationEvent = {
	id: number;
	title: string;
	body: string;
	icon: string;
	silent: boolean;
};


================================================
FILE: source/notifications-isolated.ts
================================================
((window, notification) => {
	const notifications = new Map<number, Notification>();

	// Handle events sent from the browser process
	window.addEventListener('message', ({data: {type, data}}) => {
		if (type === 'notification-callback') {
			const {callbackName, id}: NotificationCallback = data;
			const notification = notifications.get(id);

			if (!notification) {
				return;
			}

			if (notification[callbackName]) {
				notification[callbackName]();
			}

			if (callbackName === 'onclose') {
				notifications.delete(id);
			}
		}

		if (type === 'notification-reply-callback') {
			const {callbackName, id, previousConversation, reply}: NotificationReplyCallback = data;
			const notification = notifications.get(id);

			if (!notification) {
				return;
			}

			if (notification[callbackName]) {
				notification[callbackName]();
			}

			notifications.delete(id);
			window.postMessage({type: 'notification-reply', data: {previousConversation, reply}}, '*');
		}
	});

	let counter = 1;

	const augmentedNotification = Object.assign(
		class {
			private readonly _id: number;

			constructor(title: string, options: NotificationOptions) {
				// According to https://github.com/sindresorhus/caprine/pull/637, the Notification
				// constructor can be called with non-string title and body.
				let {body} = options;
				const bodyProperties = (body as any).props;
				body = bodyProperties ? bodyProperties.content[0] : options.body;

				const titleProperties = (title as any).props;
				title = titleProperties ? titleProperties.content[0] : title;

				this._id = counter++;

				notifications.set(this._id, this as any);

				window.postMessage(
					{
						type: 'notification',
						data: {
							title,
							id: this._id,
							...options,
							body,
						},
					},
					'*',
				);
			}

			// No-op, but Messenger expects this method to be present
			close(): void {} // eslint-disable-line @typescript-eslint/no-empty-function
		},
		notification,
	);

	Object.assign(window, {notification: augmentedNotification});
})(window, Notification);


================================================
FILE: source/notifications.d.ts
================================================
type NotificationCallback = {
	callbackName: keyof Notification;
	id: number;
};

type NotificationReplyCallback = NotificationCallback & {
	previousConversation: number;
	reply: string;
};


================================================
FILE: source/spell-checker.ts
================================================
import {session, MenuItemConstructorOptions} from 'electron';
import config from './config';

const languageToCode = new Map<string, string>([
	// All languages available in Electron's spellchecker
	['af', 'Afrikaans'],
	['bg', 'Bulgarian'],
	['ca', 'Catalan'],
	['cs', 'Czech'],
	['cy', 'Welsh'],
	['da', 'Danish '],
	['de', 'German'],
	['el', 'Greek'],
	['en', 'English'],
	['en-AU', 'English (Australia)'],
	['en-CA', 'English (Canada)'],
	['en-GB', 'English (United Kingdom)'],
	['en-US', 'English (United States)'],
	['es', 'Spanish'],
	['es-ES', 'Spanish'],
	['es-419', 'Spanish (Central and South America)'],
	['es-AR', 'Spanish (Argentina)'],
	['es-MX', 'Spanish (Mexico)'],
	['es-US', 'Spanish (United States)'],
	['et', 'Estonian'],
	['fa', 'Persian'],
	['fo', 'Faroese'],
	['fr', 'French'],
	['he', 'Hebrew'],
	['hi', 'Hindi'],
	['hr', 'Croatian'],
	['hu', 'Hungarian'],
	['hy', 'Armenian'],
	['id', 'Indonesian'],
	['it', 'Italian'],
	['ko', 'Korean'],
	['lt', 'Lithuanian'],
	['lv', 'Latvian'],
	['nb', 'Norwegian'],
	['nl', 'Dutch'],
	['pl', 'Polish'],
	['pt', 'Portuguese'],
	['pt-BR', 'Portuguese (Brazil)'],
	['pt-PT', 'Portuguese'],
	['ro', 'Moldovan'],
	['ru', 'Russian'],
	['sh', 'Serbo-Croatian'],
	['sk', 'Slovak'],
	['sl', 'Slovenian'],
	['sq', 'Albanian'],
	['sr', 'Serbian'],
	['sv', 'Swedish'],
	['ta', 'Tamil'],
	['tg', 'Tajik'],
	['tr', 'Turkish'],
	['uk', 'Ukrainian'],
	['vi', 'Vietnamese'],
]);

function getSpellCheckerLanguages(): MenuItemConstructorOptions[] {
	const availableLanguages = session.defaultSession.availableSpellCheckerLanguages;
	const languageItem: MenuItemConstructorOptions[] = [];
	let languagesChecked = config.get('spellCheckerLanguages');

	for (const language of languagesChecked) {
		if (!availableLanguages.includes(language)) {
			// Remove it since it's not in the spell checker dictionary.
			languagesChecked = languagesChecked.filter(currentLang => currentLang !== language);
			config.set('spellCheckerLanguages', languagesChecked);
		}
	}

	for (const language of availableLanguages) {
		languageItem.push(
			{
				label: languageToCode.get(language) ?? languageToCode.get(language.split('-')[0]) ?? language,
				type: 'checkbox',
				checked: languagesChecked.includes(language),
				click() {
					const index = languagesChecked.indexOf(language);
					if (index > -1) {
						// Remove language
						languagesChecked.splice(index, 1);
						config.set('spellCheckerLanguages', languagesChecked);
					} else {
						// Add language
						languagesChecked = [...languagesChecked, language];
						config.set('spellCheckerLanguages', languagesChecked);
					}

					session.defaultSession.setSpellCheckerLanguages(languagesChecked);
				},
			},
		);
	}

	if (languageItem.length === 1) {
		return [
			{
				label: 'System Default',
				type: 'checkbox',
				checked: true,
				enabled: false,
			},
		];
	}

	return languageItem;
}

export default getSpellCheckerLanguages;


================================================
FILE: source/touch-bar.ts
================================================
import {TouchBar, nativeImage} from 'electron';
import {ipcMain as ipc} from 'electron-better-ipc';
import config from './config';
import {sendAction, getWindow} from './util';
import {caprineIconPath} from './constants';

const {TouchBarButton} = TouchBar;
const MAX_VISIBLE_LENGTH = 25;
const privateModeTouchBarLabel: Electron.TouchBarButton = new TouchBarButton({
	label: 'Private mode enabled',
	icon: nativeImage.createFromPath(caprineIconPath),
	iconPosition: 'left',
});

function setTouchBar(items: Electron.TouchBarButton[]): void {
	const touchBar = new TouchBar({items});
	const win = getWindow();
	win.setTouchBar(touchBar);
}

function createLabel(label: string): string {
	if (label.length > MAX_VISIBLE_LENGTH) {
		// If the label is too long, we'll render a truncated one with "…" appended
		return `${label.slice(0, MAX_VISIBLE_LENGTH)}…`;
	}

	return label;
}

function createTouchBarButton({label, selected, icon}: Conversation, index: number): Electron.TouchBarButton {
	return new TouchBarButton({
		label: createLabel(label),
		backgroundColor: selected ? '#0084ff' : undefined,
		icon: nativeImage.createFromDataURL(icon),
		iconPosition: 'left',
		click() {
			sendAction('jump-to-conversation', index + 1);
		},
	});
}

ipc.answerRenderer('conversations', (conversations: Conversation[]) => {
	if (config.get('privateMode')) {
		setTouchBar([privateModeTouchBarLabel]);
	} else {
		setTouchBar(conversations.map((conversation, index) => createTouchBarButton(conversation, index)));
	}
});


================================================
FILE: source/tray.ts
================================================
import * as path from 'node:path';
import {
	app,
	Menu,
	Tray,
	BrowserWindow,
	MenuItemConstructorOptions,
} from 'electron';
import {is} from 'electron-util';
import config from './config';
import {toggleMenuBarMode} from './menu-bar-mode';

let tray: Tray | undefined;
let previousMessageCount = 0;

let contextMenu: Menu;

export default {
	create(win: BrowserWindow) {
		if (tray) {
			return;
		}

		function toggleWindow(): void {
			if (win.isVisible()) {
				win.hide();
			} else {
				if (config.get('lastWindowState').isMaximized) {
					win.maximize();
					win.focus();
				} else {
					win.show();
				}

				// Workaround for https://github.com/electron/electron/issues/20858
				// `setAlwaysOnTop` stops working after hiding the window on KDE Plasma.
				const alwaysOnTopMenuItem = Menu.getApplicationMenu()!.getMenuItemById('always-on-top')!;
				win.setAlwaysOnTop(alwaysOnTopMenuItem.checked);
			}
		}

		const macosMenuItems: MenuItemConstructorOptions[] = is.macos
			? [
				{
					label: 'Disable Menu Bar Mode',
					click() {
						config.set('menuBarMode', false);
						toggleMenuBarMode(win);
					},
				},
				{
					label: 'Show Dock Icon',
					type: 'checkbox',
					checked: config.get('showDockIcon'),
					click(menuItem) {
						config.set('showDockIcon', menuItem.checked);

						if (menuItem.checked) {
							app.dock.show();
						} else {
							app.dock.hide();
						}

						const dockMenuItem = contextMenu.getMenuItemById('dockMenu')!;
						dockMenuItem.visible = !menuItem.checked;
					},
				},
				{
					type: 'separator',
				},
				{
					id: 'dockMenu',
					label: 'Menu',
					visible: !config.get('showDockIcon'),
					submenu: Menu.getApplicationMenu()!,
				},
			] : [];

		contextMenu = Menu.buildFromTemplate([
			{
				label: 'Toggle',
				visible: !is.macos,
				click() {
					toggleWindow();
				},
			},
			...macosMenuItems,
			{
				type: 'separator',
			},
			{
				role: 'quit',
			},
		]);

		tray = new Tray(getIconPath(false));

		tray.setContextMenu(contextMenu);

		updateToolTip(0);

		const trayClickHandler = (): void => {
			if (!win.isFullScreen()) {
				toggleWindow();
			}
		};

		tray.on('click', trayClickHandler);
		tray.on('double-click', trayClickHandler);
		tray.on('right-click', () => {
			tray?.popUpContextMenu(contextMenu);
		});
	},

	destroy() {
		// Workaround for https://github.com/electron/electron/issues/14036
		setTimeout(() => {
			tray?.destroy();
			tray = undefined;
		}, 500);
	},

	update(messageCount: number) {
		if (!tray || previousMessageCount === messageCount) {
			return;
		}

		previousMessageCount = messageCount;
		tray.setImage(getIconPath(messageCount > 0));
		updateToolTip(messageCount);
	},

	setBadge(shouldDisplayUnread: boolean) {
		if (is.macos || !tray) {
			return;
		}

		tray.setImage(getIconPath(shouldDisplayUnread));
	},
};

function updateToolTip(counter: number): void {
	if (!tray) {
		return;
	}

	let tooltip = app.name;

	if (counter > 0) {
		tooltip += `- ${counter} unread ${counter === 1 ? 'message' : 'messages'}`;
	}

	tray.setToolTip(tooltip);
}

function getIconPath(hasUnreadMessages: boolean): string {
	const icon = is.macos
		? getMacOSIconName(hasUnreadMessages)
		: getNonMacOSIconName(hasUnreadMessages);

	return path.join(__dirname, '..', `static/${icon}`);
}

function getNonMacOSIconName(hasUnreadMessages: boolean): string {
	return hasUnreadMessages ? 'IconTrayUnread.png' : 'IconTray.png';
}

function getMacOSIconName(hasUnreadMessages: boolean): string {
	return hasUnreadMessages ? 'IconMenuBarUnreadTemplate.png' : 'IconMenuBarTemplate.png';
}


================================================
FILE: source/types.ts
================================================
export type IToggleSounds = {
	checked: boolean;
};

export type IToggleMuteNotifications = {
	defaultStatus: boolean;
};


================================================
FILE: source/util.ts
================================================
import {
	app,
	BrowserWindow,
	dialog,
	Menu,
} from 'electron';
import {ipcMain} from 'electron-better-ipc';
import {is} from 'electron-util';
import config from './config';
import tray from './tray';

export function getWindow(): BrowserWindow {
	const [win] = BrowserWindow.getAllWindows();
	return win;
}

export function sendAction<T>(action: string, arguments_?: T): void {
	const win = getWindow();

	if (is.macos) {
		win.restore();
	}

	ipcMain.callRenderer(win, action, arguments_);
}

export async function sendBackgroundAction<T, ReturnValue>(action: string, arguments_?: T): Promise<ReturnValue> {
	return ipcMain.callRenderer<T, ReturnValue>(getWindow(), action, arguments_);
}

export function showRestartDialog(message: string): void {
	const buttonIndex = dialog.showMessageBoxSync(
		getWindow(),
		{
			message,
			detail: 'Do you want to restart the app now?',
			buttons: [
				'Restart',
				'Ignore',
			],
			defaultId: 0,
			cancelId: 1,
		},
	);

	if (buttonIndex === 0) {
		app.relaunch();
		app.quit();
	}
}

export const messengerDomain = config.get('useWorkChat') ? 'facebook.com' : 'messenger.com';

export function stripTrackingFromUrl(url: string): string {
	const trackingUrlPrefix = `https://l.${messengerDomain}/l.php`;
	if (url.startsWith(trackingUrlPrefix)) {
		url = new URL(url).searchParams.get('u')!;
	}

	return url;
}

export const toggleTrayIcon = (): void => {
	const showTrayIconState = config.get('showTrayIcon');
	config.set('showTrayIcon', !showTrayIconState);

	if (showTrayIconState) {
		tray.destroy();
	} else {
		tray.create(getWindow());
	}
};

export const toggleLaunchMinimized = (menu: Menu): void => {
	config.set('launchMinimized', !config.get('launchMinimized'));
	const showTrayIconItem = menu.getMenuItemById('showTrayIcon')!;

	if (config.get('launchMinimized')) {
		if (!config.get('showTrayIcon')) {
			toggleTrayIcon();
		}

		disableMenuItem(showTrayIconItem, true);

		dialog.showMessageBox({
			type: 'info',
			message: 'The “Show Tray Icon” setting is force-enabled while the “Launch Minimized” setting is enabled.',
			buttons: ['OK'],
		});
	} else {
		showTrayIconItem.enabled = true;
	}
};

const disableMenuItem = (menuItem: Electron.MenuItem, checked: boolean): void => {
	menuItem.enabled = false;
	menuItem.checked = checked;
};


================================================
FILE: static/readme.md
================================================
# Notes

## Convert `IconTrayUnread` to black & white

In Photoshop, click `Image → Adjustments → Black & White`, choose the `Default` preset, and then change `Blues` to `-10%`.


================================================
FILE: tsconfig.json
================================================
{
	"extends": "@sindresorhus/tsconfig",
	"compilerOptions": {
		"outDir": "dist-js",
		"target": "ES2022",
		"module": "commonjs",
		"esModuleInterop": true,
		"lib": [
			"esnext",
			"dom",
			"dom.iterable"
		]
	}
}
Download .txt
gitextract_9iro8akf/

├── .editorconfig
├── .gitattributes
├── .github/
│   ├── dependabot.yml
│   └── workflows/
│       ├── build.yml
│       └── tests.yml
├── .gitignore
├── .nvmrc
├── .prettierignore
├── build/
│   ├── entitlements.mac.plist
│   └── icon.icns
├── css/
│   ├── autoplay.css
│   ├── browser.css
│   ├── code-blocks.css
│   ├── dark-mode.css
│   ├── scrollbar.css
│   ├── vibrancy.css
│   └── workchat.css
├── license
├── media/
│   └── AppIcon.sketch
├── 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/
│   │   ├── conversation-list.ts
│   │   └── selectors.ts
│   ├── browser-call.ts
│   ├── browser.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/
│   └── readme.md
└── tsconfig.json
Download .txt
SYMBOL INDEX (113 symbols across 18 files)

FILE: source/autoplay.ts
  function toggleVideoAutoplay (line 7) | async function toggleVideoAutoplay(): Promise<void> {
  function disableVideoAutoplay (line 29) | function disableVideoAutoplay(videos: NodeListOf<HTMLVideoElement>): void {
  function enableVideoAutoplay (line 109) | function enableVideoAutoplay(): void {
  function getVideos (line 117) | function getVideos(): NodeListOf<HTMLVideoElement> {
  function startConversationWindowObserver (line 121) | function startConversationWindowObserver(): void {
  function startVideoObserver (line 128) | function startVideoObserver(element: Element): void {

FILE: source/browser.ts
  function withMenu (line 11) | async function withMenu(
  function isNewSidebar (line 43) | async function isNewSidebar(): Promise<boolean> {
  function withSettingsMenu (line 52) | async function withSettingsMenu(callback: () => Promise<void> | void): P...
  function selectMenuItem (line 59) | async function selectMenuItem(itemNumber: number): Promise<void> {
  function selectOtherListViews (line 81) | async function selectOtherListViews(itemNumber: number): Promise<void> {
  function clickBackButton (line 104) | function clickBackButton(): void {
  function openSearchInConversation (line 152) | async function openSearchInConversation() {
  function openHiddenPreferences (line 221) | async function openHiddenPreferences(): Promise<boolean> {
  function toggleSounds (line 233) | async function toggleSounds({checked}: IToggleSounds): Promise<void> {
  function setTheme (line 296) | async function setTheme(): Promise<void> {
  function setThemeElement (line 304) | function setThemeElement(element: HTMLElement): void {
  function removeThemeClasses (line 313) | function removeThemeClasses(useDarkColors: boolean): void {
  function observeTheme (line 323) | async function observeTheme(): Promise<void> {
  function setPrivateMode (line 367) | async function setPrivateMode(): Promise<void> {
  function updateVibrancy (line 376) | async function updateVibrancy(): Promise<void> {
  function updateSidebar (line 400) | async function updateSidebar(): Promise<void> {
  function updateDoNotDisturb (line 427) | async function updateDoNotDisturb(): Promise<void> {
  function renderOverlayIcon (line 437) | function renderOverlayIcon(messageCount: number): HTMLCanvasElement {
  function nextConversation (line 520) | async function nextConversation(): Promise<void> {
  function previousConversation (line 528) | async function previousConversation(): Promise<void> {
  function jumpToConversation (line 536) | async function jumpToConversation(key: number): Promise<void> {
  function selectConversation (line 542) | async function selectConversation(index: number): Promise<void> {
  function selectedConversationIndex (line 560) | function selectedConversationIndex(offset = 0): number {
  function setZoom (line 575) | async function setZoom(zoomFactor: number): Promise<void> {
  function withConversationMenu (line 581) | async function withConversationMenu(callback: () => void): Promise<void> {
  function openMuteModal (line 593) | async function openMuteModal(): Promise<void> {
  function isSelectedConversationGroup (line 606) | function isSelectedConversationGroup(): boolean {
  function isSelectedConversationMetaAI (line 611) | function isSelectedConversationMetaAI(): boolean {
  function archiveSelectedConversation (line 616) | async function archiveSelectedConversation(): Promise<void> {
  function deleteSelectedConversation (line 633) | async function deleteSelectedConversation(): Promise<void> {
  function openPreferences (line 650) | async function openPreferences(): Promise<void> {
  function isPreferencesOpen (line 658) | function isPreferencesOpen(): boolean {
  function closePreferences (line 662) | async function closePreferences(): Promise<void> {
  function insertionListener (line 685) | function insertionListener(event: AnimationEvent): void {
  function observeAutoscroll (line 691) | async function observeAutoscroll(): Promise<void> {
  function observeThemeBugs (line 744) | async function observeThemeBugs(): Promise<void> {
  function showNotification (line 879) | function showNotification({id, title, body, icon, silent}: NotificationE...
  function sendReply (line 903) | async function sendReply(message: string): Promise<void> {
  function insertMessageText (line 929) | function insertMessageText(text: string, inputField: HTMLElement): void {

FILE: source/browser/conversation-list.ts
  function drawIcon (line 18) | function drawIcon(size: number, img?: HTMLImageElement): HTMLCanvasEleme...
  function urlToCanvas (line 41) | async function urlToCanvas(url: string, size: number): Promise<HTMLCanva...
  function createIcons (line 60) | async function createIcons(element: HTMLElement, url: string): Promise<v...
  function discoverIcons (line 77) | async function discoverIcons(element: HTMLElement): Promise<void> {
  function getIcon (line 95) | async function getIcon(element: HTMLElement, unread: boolean): Promise<s...
  function getLabel (line 107) | async function getLabel(element: HTMLElement): Promise<string> {
  function createConversationNewDesign (line 126) | async function createConversationNewDesign(element: HTMLElement): Promis...
  function createConversationList (line 145) | async function createConversationList(): Promise<Conversation[]> {
  function sendConversationList (line 167) | async function sendConversationList(): Promise<void> {
  function generateStringFromNode (line 172) | function generateStringFromNode(element: Element): string | undefined {
  function countUnread (line 190) | function countUnread(mutationsList: MutationRecord[]): void {
  function updateTrayIcon (line 262) | async function updateTrayIcon(): Promise<void> {

FILE: source/config.ts
  type StoreType (line 5) | type StoreType = {
  function updateVibrancySetting (line 223) | function updateVibrancySetting(store: Store<StoreType>): void {
  function updateSidebarSetting (line 237) | function updateSidebarSetting(store: Store<StoreType>): void {
  function updateThemeSetting (line 247) | function updateThemeSetting(store: Store<StoreType>): void {
  function migrate (line 270) | function migrate(store: Store<StoreType>): void {

FILE: source/conversation.d.ts
  type Conversation (line 1) | type Conversation = {

FILE: source/emoji.ts
  type EmojiStyle (line 207) | enum EmojiStyle {
  type EmojiStyleCode (line 214) | enum EmojiStyleCode {
  function codeForEmojiStyle (line 220) | function codeForEmojiStyle(style: EmojiStyle): EmojiStyleCode {
  function urlToEmoji (line 244) | function urlToEmoji(url: string): string {
  function getEmojiIcon (line 269) | async function getEmojiIcon(style: EmojiStyle): Promise<NativeImage | un...
  function process (line 305) | async function process(url: string): Promise<CallbackResponse> {
  function generateSubmenu (line 341) | async function generateSubmenu(

FILE: source/ensure-online.ts
  function showWaitDialog (line 5) | function showWaitDialog(): void {

FILE: source/index.ts
  method prepend (line 49) | prepend(defaultActions) {
  function updateBadge (line 107) | async function updateBadge(messageCount: number): Promise<void> {
  function updateOverlayIcon (line 146) | function updateOverlayIcon({data, text}: {data: string; text: string}): ...
  type BeforeSendHeadersResponse (line 151) | type BeforeSendHeadersResponse = {
  type OnSendHeadersDetails (line 156) | type OnSendHeadersDetails = {
  function enableHiresResources (line 167) | function enableHiresResources(): void {
  function initRequestsFiltering (line 197) | function initRequestsFiltering(): void {
  function setUserLocale (line 236) | function setUserLocale(): void {
  function setNotificationsMute (line 248) | function setNotificationsMute(status: boolean): void {
  function createMainWindow (line 261) | function createMainWindow(): BrowserWindow {
  method click (line 387) | async click() {
  method click (line 414) | click() {
  function toggleMaximized (line 584) | function toggleMaximized(): void {
  type ThemeSource (line 665) | type ThemeSource = typeof nativeTheme.themeSource;

FILE: source/menu-bar-mode.ts
  function toggleMenuBarMode (line 13) | function toggleMenuBarMode(window: BrowserWindow): void {
  function setUpMenuBarMode (line 40) | function setUpMenuBarMode(window: BrowserWindow): void {

FILE: source/menu.ts
  function updateMenu (line 31) | async function updateMenu(): Promise<Menu> {

FILE: source/notification-event.d.ts
  type NotificationEvent (line 1) | type NotificationEvent = {

FILE: source/notifications-isolated.ts
  method constructor (line 46) | constructor(title: string, options: NotificationOptions) {
  method close (line 75) | close(): void {} // eslint-disable-line @typescript-eslint/no-empty-func...

FILE: source/notifications.d.ts
  type NotificationCallback (line 1) | type NotificationCallback = {
  type NotificationReplyCallback (line 6) | type NotificationReplyCallback = NotificationCallback & {

FILE: source/spell-checker.ts
  function getSpellCheckerLanguages (line 60) | function getSpellCheckerLanguages(): MenuItemConstructorOptions[] {

FILE: source/touch-bar.ts
  constant MAX_VISIBLE_LENGTH (line 8) | const MAX_VISIBLE_LENGTH = 25;
  function setTouchBar (line 15) | function setTouchBar(items: Electron.TouchBarButton[]): void {
  function createLabel (line 21) | function createLabel(label: string): string {
  function createTouchBarButton (line 30) | function createTouchBarButton({label, selected, icon}: Conversation, ind...

FILE: source/tray.ts
  method create (line 19) | create(win: BrowserWindow) {
  method destroy (line 115) | destroy() {
  method update (line 123) | update(messageCount: number) {
  method setBadge (line 133) | setBadge(shouldDisplayUnread: boolean) {
  function updateToolTip (line 142) | function updateToolTip(counter: number): void {
  function getIconPath (line 156) | function getIconPath(hasUnreadMessages: boolean): string {
  function getNonMacOSIconName (line 164) | function getNonMacOSIconName(hasUnreadMessages: boolean): string {
  function getMacOSIconName (line 168) | function getMacOSIconName(hasUnreadMessages: boolean): string {

FILE: source/types.ts
  type IToggleSounds (line 1) | type IToggleSounds = {
  type IToggleMuteNotifications (line 5) | type IToggleMuteNotifications = {

FILE: source/util.ts
  function getWindow (line 12) | function getWindow(): BrowserWindow {
  function sendAction (line 17) | function sendAction<T>(action: string, arguments_?: T): void {
  function sendBackgroundAction (line 27) | async function sendBackgroundAction<T, ReturnValue>(action: string, argu...
  function showRestartDialog (line 31) | function showRestartDialog(message: string): void {
  function stripTrackingFromUrl (line 54) | function stripTrackingFromUrl(url: string): string {
Condensed preview — 50 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (192K chars).
[
  {
    "path": ".editorconfig",
    "chars": 175,
    "preview": "root = true\n\n[*]\nindent_style = tab\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newlin"
  },
  {
    "path": ".gitattributes",
    "chars": 19,
    "preview": "* text=auto eol=lf\n"
  },
  {
    "path": ".github/dependabot.yml",
    "chars": 467,
    "preview": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where "
  },
  {
    "path": ".github/workflows/build.yml",
    "chars": 1829,
    "preview": "name: Build and publish\non:\n  push:\n    tags:\n      - '*'\njobs:\n  tests:\n    uses: ./.github/workflows/tests.yml\n  build"
  },
  {
    "path": ".github/workflows/tests.yml",
    "chars": 1554,
    "preview": "name: Tests\n\non:\n  push:\n    branches-ignore:\n      - gh-pages\n  pull_request:\n  workflow_call:\n\njobs:\n  npm-cache:\n    "
  },
  {
    "path": ".gitignore",
    "chars": 92,
    "preview": "node_modules\nyarn.lock\n/dist\n/dist-js\nmas.provisionprofile\n.vscode\n.DS_store\n/.devcontainer\n"
  },
  {
    "path": ".nvmrc",
    "chars": 13,
    "preview": "lts/hydrogen\n"
  },
  {
    "path": ".prettierignore",
    "chars": 5,
    "preview": "*.md\n"
  },
  {
    "path": "build/entitlements.mac.plist",
    "chars": 507,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"https://www.apple.com/DTDs/"
  },
  {
    "path": "css/autoplay.css",
    "chars": 208,
    "preview": ".disabledAutoPlayImgTopRadius {\n\tborder-top-left-radius: 1.3em;\n\tborder-top-right-radius: 1.3em;\n}\n\n.disabledAutoPlayImg"
  },
  {
    "path": "css/browser.css",
    "chars": 10309,
    "preview": ":root {\n\t--selected-conversation-background: linear-gradient(hsla(209deg 110% 45% / 90%), hsla(209deg 110% 42% / 90%));\n"
  },
  {
    "path": "css/code-blocks.css",
    "chars": 3117,
    "preview": "/* Tomorrow light theme for code blocks */\n._wu0 {\n\t--code-block-base: #1d1f21;\n\t--code-block-background: transparent;\n\t"
  },
  {
    "path": "css/dark-mode.css",
    "chars": 4645,
    "preview": ":root {\n\t--base: #000;\n\t--base-ninety: rgb(255 255 255 / 90%);\n\t--base-seventy-five: rgb(255 255 255 / 75%);\n\t--base-sev"
  },
  {
    "path": "css/scrollbar.css",
    "chars": 752,
    "preview": "/* Custom native scrollbar */\n\n/* Light theme */\n::-webkit-scrollbar {\n\twidth: 16px;\n}\n::-webkit-scrollbar-thumb {\n\tbox-"
  },
  {
    "path": "css/vibrancy.css",
    "chars": 5469,
    "preview": "/* -- BLOCK START: sidebar-only vibrancy -- */\n\nhtml.sidebar-vibrancy body,\nhtml.sidebar-vibrancy ._4sp8,\nhtml.sidebar-v"
  },
  {
    "path": "css/workchat.css",
    "chars": 1645,
    "preview": "/* Main: Hide header */\n#pagelet_bluebar {\n\tdisplay: none;\n}\n\n/* Login: Remove vertical scrollbar */\nbody {\n\toverflow: h"
  },
  {
    "path": "license",
    "chars": 1109,
    "preview": "MIT License\n\nCopyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)\n\nPermission is hereby granted, free"
  },
  {
    "path": "package.json",
    "chars": 4747,
    "preview": "{\n\t\"name\": \"caprine\",\n\t\"productName\": \"Caprine\",\n\t\"version\": \"2.61.0\",\n\t\"description\": \"Elegant Facebook Messenger deskt"
  },
  {
    "path": "packages/deb/addRepo.sh",
    "chars": 2837,
    "preview": "#!/bin/bash\n# Made by Lefteris Garyfalakis\n# Last update: 25-08-2022\n\nCOL_NC='\\e[0m' # No Color\nCOL_LIGHT_RED='\\e[1;31m'"
  },
  {
    "path": "packages/rpm/caprine.desktop",
    "chars": 235,
    "preview": "[Desktop Entry]\nType=Application\nName=Caprine\nGenericName=IM Client\nComment=Elegant Facebook Messenger desktop app\nIcon="
  },
  {
    "path": "packages/rpm/caprine.spec",
    "chars": 4241,
    "preview": "%define debug_package %{nil}\n%global _build_id_links alldebug\n\nName:           caprine\nVersion:        2.61.0\nRelease:  "
  },
  {
    "path": "patches/electron-debug++electron-is-dev+1.2.0.patch",
    "chars": 675,
    "preview": "diff --git a/node_modules/electron-debug/node_modules/electron-is-dev/index.js b/node_modules/electron-debug/node_module"
  },
  {
    "path": "patches/electron-util++electron-is-dev+1.2.0.patch",
    "chars": 671,
    "preview": "diff --git a/node_modules/electron-util/node_modules/electron-is-dev/index.js b/node_modules/electron-util/node_modules/"
  },
  {
    "path": "readme.md",
    "chars": 14104,
    "preview": "<div align=\"center\">\n\t<br>\n\t<br>\n\t<a href=\"https://github.com/sindresorhus/caprine\">\n\t\t<img src=\"media/AppIcon-readme.pn"
  },
  {
    "path": "source/autoplay.ts",
    "chars": 5674,
    "preview": "import {ipcRenderer as ipc} from 'electron-better-ipc';\nimport selectors from './browser/selectors';\n\nconst conversation"
  },
  {
    "path": "source/browser/conversation-list.ts",
    "chars": 9285,
    "preview": "import {ipcRenderer as ipc} from 'electron-better-ipc';\nimport elementReady from 'element-ready';\nimport {isNull} from '"
  },
  {
    "path": "source/browser/selectors.ts",
    "chars": 3756,
    "preview": "export default {\n\tleftSidebar: '[role=\"navigation\"][class=\"x9f619 x1n2onr6 x1ja2u2z x78zum5 xdt5ytf x2lah0s x193iq5w xeu"
  },
  {
    "path": "source/browser-call.ts",
    "chars": 204,
    "preview": "import elementReady from 'element-ready';\n\n(async () => {\n\tconst startCallButton = (await elementReady<HTMLElement>('._3"
  },
  {
    "path": "source/browser.ts",
    "chars": 28269,
    "preview": "import process from 'node:process';\nimport {ipcRenderer as ipc} from 'electron-better-ipc';\nimport {is} from 'electron-u"
  },
  {
    "path": "source/config.ts",
    "chars": 5463,
    "preview": "import Store from 'electron-store';\nimport {is} from 'electron-util';\nimport {EmojiStyle} from './emoji';\n\nexport type S"
  },
  {
    "path": "source/constants.ts",
    "chars": 191,
    "preview": "import * as path from 'node:path';\nimport {fixPathForAsarUnpack} from 'electron-util';\n\nexport const caprineIconPath = f"
  },
  {
    "path": "source/conversation.d.ts",
    "chars": 94,
    "preview": "type Conversation = {\n\tlabel: string;\n\tselected: boolean;\n\tunread: boolean;\n\ticon: string;\n};\n"
  },
  {
    "path": "source/do-not-disturb.d.ts",
    "chars": 47,
    "preview": "declare module '@sindresorhus/do-not-disturb';\n"
  },
  {
    "path": "source/emoji.ts",
    "chars": 8757,
    "preview": "import * as path from 'node:path';\nimport {\n\tnativeImage,\n\tNativeImage,\n\tMenuItemConstructorOptions,\n\tCallbackResponse,\n"
  },
  {
    "path": "source/ensure-online.ts",
    "chars": 664,
    "preview": "import {app, dialog} from 'electron';\nimport isOnline from 'is-online';\nimport pWaitFor from 'p-wait-for';\n\nfunction sho"
  },
  {
    "path": "source/index.ts",
    "chars": 19411,
    "preview": "import path from 'node:path';\nimport {readFileSync, existsSync} from 'node:fs';\nimport {\n\tapp,\n\tnativeImage,\n\tscreen as "
  },
  {
    "path": "source/menu-bar-mode.ts",
    "chars": 996,
    "preview": "import {\n\tapp,\n\tglobalShortcut,\n\tBrowserWindow,\n\tMenu,\n} from 'electron';\nimport {is} from 'electron-util';\nimport confi"
  },
  {
    "path": "source/menu.ts",
    "chars": 19262,
    "preview": "import * as path from 'node:path';\nimport {existsSync, writeFileSync} from 'node:fs';\nimport {\n\tapp,\n\tshell,\n\tMenu,\n\tMen"
  },
  {
    "path": "source/notification-event.d.ts",
    "chars": 107,
    "preview": "type NotificationEvent = {\n\tid: number;\n\ttitle: string;\n\tbody: string;\n\ticon: string;\n\tsilent: boolean;\n};\n"
  },
  {
    "path": "source/notifications-isolated.ts",
    "chars": 2078,
    "preview": "((window, notification) => {\n\tconst notifications = new Map<number, Notification>();\n\n\t// Handle events sent from the br"
  },
  {
    "path": "source/notifications.d.ts",
    "chars": 190,
    "preview": "type NotificationCallback = {\n\tcallbackName: keyof Notification;\n\tid: number;\n};\n\ntype NotificationReplyCallback = Notif"
  },
  {
    "path": "source/spell-checker.ts",
    "chars": 2946,
    "preview": "import {session, MenuItemConstructorOptions} from 'electron';\nimport config from './config';\n\nconst languageToCode = new"
  },
  {
    "path": "source/touch-bar.ts",
    "chars": 1515,
    "preview": "import {TouchBar, nativeImage} from 'electron';\nimport {ipcMain as ipc} from 'electron-better-ipc';\nimport config from '"
  },
  {
    "path": "source/tray.ts",
    "chars": 3621,
    "preview": "import * as path from 'node:path';\nimport {\n\tapp,\n\tMenu,\n\tTray,\n\tBrowserWindow,\n\tMenuItemConstructorOptions,\n} from 'ele"
  },
  {
    "path": "source/types.ts",
    "chars": 122,
    "preview": "export type IToggleSounds = {\n\tchecked: boolean;\n};\n\nexport type IToggleMuteNotifications = {\n\tdefaultStatus: boolean;\n}"
  },
  {
    "path": "source/util.ts",
    "chars": 2311,
    "preview": "import {\n\tapp,\n\tBrowserWindow,\n\tdialog,\n\tMenu,\n} from 'electron';\nimport {ipcMain} from 'electron-better-ipc';\nimport {i"
  },
  {
    "path": "static/readme.md",
    "chars": 178,
    "preview": "# Notes\n\n## Convert `IconTrayUnread` to black & white\n\nIn Photoshop, click `Image → Adjustments → Black & White`, choose"
  },
  {
    "path": "tsconfig.json",
    "chars": 218,
    "preview": "{\n\t\"extends\": \"@sindresorhus/tsconfig\",\n\t\"compilerOptions\": {\n\t\t\"outDir\": \"dist-js\",\n\t\t\"target\": \"ES2022\",\n\t\t\"module\": \""
  }
]

// ... and 2 more files (download for full content)

About this extraction

This page contains the full source code of the sindresorhus/caprine GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 50 files (170.7 KB), approximately 53.7k tokens, and a symbol index with 113 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!