Full Code of PLhery/unfollowNinja for AI

master b10bfac49562 cached
137 files
329.4 KB
85.2k tokens
186 symbols
1 requests
Download .txt
Showing preview only (367K chars total). Download the full file or copy to clipboard to get everything.
Repository: PLhery/unfollowNinja
Branch: master
Commit: b10bfac49562
Files: 137
Total size: 329.4 KB

Directory structure:
gitextract_xza4tmml/

├── .github/
│   └── workflows/
│       └── server-ci.yml
├── .gitignore
├── license.md
├── readme.md
├── unfollow-monkey-ui/
│   ├── .gitignore
│   ├── package.json
│   ├── public/
│   │   ├── _redirects
│   │   ├── favicon/
│   │   │   └── site.webmanifest
│   │   ├── index.html
│   │   ├── manifest.json
│   │   ├── privacy-policy.txt
│   │   ├── robots.txt
│   │   ├── sitemap.xml
│   │   └── tos.txt
│   └── src/
│       ├── App.js
│       ├── components/
│       │   ├── Faq.js
│       │   ├── Faq.module.scss
│       │   ├── Link.js
│       │   ├── MiniApp/
│       │   │   ├── LanguageSelector.js
│       │   │   ├── LanguageSelector.module.scss
│       │   │   ├── ProCard.js
│       │   │   └── ProCard.module.scss
│       │   ├── MiniApp.js
│       │   ├── MiniApp.module.scss
│       │   ├── Navbar.js
│       │   ├── Navbar.module.scss
│       │   ├── Repo.js
│       │   ├── Repo.module.scss
│       │   ├── Section.js
│       │   ├── Section.module.scss
│       │   └── index.js
│       ├── images/
│       │   ├── flags/
│       │   │   └── index.js
│       │   └── index.js
│       ├── index.js
│       ├── reportWebVitals.js
│       ├── service-worker.js
│       ├── serviceWorkerRegistration.js
│       ├── style.scss
│       └── twemojis/
│           ├── Emojis.js
│           └── Emojis.module.scss
├── unfollow-ninja-server/
│   ├── .dockerignore
│   ├── .eslintrc.json
│   ├── .prettierignore
│   ├── .prettierrc.json
│   ├── Dockerfile
│   ├── docker-compose.yml
│   ├── jest.config.js
│   ├── locales/
│   │   ├── ar.json
│   │   ├── de.json
│   │   ├── en.json
│   │   ├── es.json
│   │   ├── fr.json
│   │   ├── hy.json
│   │   ├── id.json
│   │   ├── nl.json
│   │   ├── pl.json
│   │   ├── pt.json
│   │   ├── pt_BR.json
│   │   ├── ru.json
│   │   ├── sk.json
│   │   ├── th.json
│   │   ├── tr.json
│   │   ├── uk.json
│   │   ├── zgh.json
│   │   └── zh_Hans.json
│   ├── package.json
│   ├── pm2.yml
│   ├── src/
│   │   ├── api/
│   │   │   ├── admin.ts
│   │   │   ├── auth.ts
│   │   │   ├── stripe.ts
│   │   │   └── user.ts
│   │   ├── api.ts
│   │   ├── dao/
│   │   │   ├── dao.ts
│   │   │   ├── userDao.ts
│   │   │   └── userEventDao.ts
│   │   ├── jobs/
│   │   │   ├── cleanUsersWithRevokedTokens.ts
│   │   │   ├── deleteRedisSnowflakeIds.ts
│   │   │   ├── emptyQueue.ts
│   │   │   ├── migrateCachedUsernamesFromRedisToPostres.ts
│   │   │   ├── migrateFollowersFromRedisToPostres.ts
│   │   │   ├── resetCachedSnowflakeIds.ts
│   │   │   ├── setUsersLanguage.ts
│   │   │   └── twitExperiment.ts
│   │   ├── tasks/
│   │   │   ├── index.ts
│   │   │   ├── notifyUser.ts
│   │   │   ├── reenableFollowers.ts
│   │   │   ├── sendWelcomeMessage.ts
│   │   │   ├── task.ts
│   │   │   └── updateMetrics.ts
│   │   ├── utils/
│   │   │   ├── logger.ts
│   │   │   ├── metrics.ts
│   │   │   ├── types.ts
│   │   │   └── utils.ts
│   │   ├── workers/
│   │   │   ├── cacheAllFollowers.ts
│   │   │   └── checkAllFollowers.ts
│   │   └── workers.ts
│   ├── tests/
│   │   ├── dao/
│   │   │   ├── __snapshots__/
│   │   │   │   └── userDao.spec.ts.snap
│   │   │   ├── dao.spec.ts
│   │   │   ├── userDao.spec.ts
│   │   │   └── userEventDao.spec.ts
│   │   ├── docker-compose.yml
│   │   ├── tasks/
│   │   │   ├── cacheFollowers.spec.ts.disabled
│   │   │   ├── checkFollowers.spec.ts.disabled
│   │   │   ├── notifyUser.spec.ts
│   │   │   └── sendWelcomeMessage.spec.ts
│   │   └── utils.ts
│   ├── tsconfig-build.json
│   └── tsconfig.json
└── unfollow-ninja-ui/
    ├── .gitignore
    ├── package.json
    ├── public/
    │   ├── _redirects
    │   ├── favicon/
    │   │   └── site.webmanifest
    │   ├── index.html
    │   ├── manifest.json
    │   ├── robots.txt
    │   └── sitemap.xml
    └── src/
        ├── App.js
        ├── components/
        │   ├── Faq.js
        │   ├── Faq.module.scss
        │   ├── Link.js
        │   ├── MiniApp.js
        │   ├── MiniApp.module.scss
        │   ├── Navbar.js
        │   ├── Navbar.module.scss
        │   ├── Repo.js
        │   ├── Repo.module.scss
        │   ├── Section.js
        │   ├── Section.module.scss
        │   └── index.js
        ├── images/
        │   └── index.js
        ├── index.js
        ├── reportWebVitals.js
        ├── service-worker.js
        ├── serviceWorkerRegistration.js
        ├── style.scss
        └── twemojis/
            ├── Emojis.js
            └── Emojis.module.scss

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

================================================
FILE: .github/workflows/server-ci.yml
================================================
name: Server CI
on: push

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18.x]
    steps:
      - name: checkout
        uses: actions/checkout@v2
      - name: Use node ${{ matrix.node-version }}
        uses: actions/setup-node@v1
        with:
          node-version: ${{ matrix.node-version }}
      - name: cache npm modules
        uses: actions/cache@v1
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-
      - name: npm ci
        working-directory: unfollow-ninja-server
        run: npm ci
      - name: lint
        working-directory: unfollow-ninja-server
        run: npm run lint
      - name: build
        working-directory: unfollow-ninja-server
        run: npm run build
      - name: test
        working-directory: unfollow-ninja-server
        run: npm run specs

  docker:
    runs-on: ubuntu-latest
    steps:
      - name: checkout
        uses: actions/checkout@v2
      - name: create an empty env file
        working-directory: unfollow-ninja-server
        run: touch .env
      - name: build
        working-directory: unfollow-ninja-server/tests
        run: docker-compose build
      - name: run tests
        working-directory: unfollow-ninja-server/tests
        run: docker-compose up --exit-code-from tests


================================================
FILE: .gitignore
================================================
# Based on https://github.com/github/gitignore/blob/master/Node.gitignore
#
# Logs
*.log
*.log.gz
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage

# nyc test coverage
.nyc_output

# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# Bower dependency directory (https://bower.io/)
bower_components

# node-waf configuration
.lock-wscript

# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release

# Dependency directories
node_modules/
jspm_packages/

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache

# Optional REPL history
.node_repl_history

# Output of 'npm pack'
*.tgz

# Yarn Integrity file
.yarn-integrity

# dotenv environment variables file
.env

# vuepress build output
.vuepress/dist

# Compiled typescript
dist/

# intellij idea
.idea/

# test reports
test-results/

# osX
.DS_Store

================================================
FILE: license.md
================================================
Copyright 2019 Paul-Louis Hery https://twitter.com/plhery

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

================================================
FILE: readme.md
================================================
# Unfollow Ninja [![Server CI status](https://github.com/PLhery/unfollowNinja/workflows/Server%20CI/badge.svg)](https://github.com/PLhery/unfollowNinja/actions?query=workflow%3A%22Server+CI%22)

Get notified when your Twitter account loses a follower

Used by ~500 000 Twitter users (11/2021)

🇬🇧 https://unfollow-monkey.com
🇫🇷 https://unfollow.ninja  

![Screenshot](https://raw.githubusercontent.com/PLhery/unfollowNinja/master/unfollow-monkey-ui/public/preview.png)

---

**[Deprecated] This app was built mostly on top of Twitter API V1.1, which is now deprecated.**



Feel free to reuse the UI on a personal server, but please don't reuse the name/logo on a public instance.

Indeed, this software is under apache v2 license which means:

- You need to aknowledge the use of this software and its license somewhere
- A modified version can't have the same name

## Build the UI

- clone the repo `git clone git@github.com:PLhery/unfollowNinja.git`
- `cd unfollowNinja/unfollow-monkey-ui`
- change `api.unfollow-monkey.com` to you own server endpoints in ui/src/components/MiniApp.js
- `npm install && npm start` to serve the UI in dev mode
- `npm run build` to build the UI static files in the `build` folder.

Then you can host it on github pages, amazon s3, netlify, your own nginx server...


## Launch the server with docker-compose

- clone the repo `git clone git@github.com:PLhery/unfollowNinja.git`
- `cd unfollowNinja/unfollow-ninja-server`
- create a .env file (see [.env file](#.env-file))
- `docker-compose up  --build`

The server will be accessible on http://localhost:4000/  

By default, the db files and logs are stored in /data subfolders. Feel free to edit these, and the port/address in `docker-compose.yml`

## Launch the server manually

- clone the repo `git clone git@github.com:PLhery/unfollowNinja.git`
- `cd unfollowNinja/unfollow-ninja-server`
- install the dependencies and build the project `npm ci && npm build`
- create a .env file (see [.env file](#.env-file))
- install redis and optionally set a custom REDIS_URI + REDIS_BULL_URI in .env
- launch the workers `node ./dist/api/workers`
- launch the api server `node ./dist/api`
- or launch both in the as a daemon with pm2 `pm2 start pm2.yml`

## .env file

To get started, you'll need to create one or two twitter app https://developer.twitter.com/en/apps:  
- One for the first step, which only need a read access, and a callback url = https://your-ui-url/1
- One for the second step, which needs a DM sending access, and a callback url = https://your-ui-url/2
- Both need to have sign in with twitter enabled

Then you can create a .env in unfollow-ninja-server to set some parameters:

```
# your first step app API key and secret
CONSUMER_KEY=xxx 
CONSUMER_SECRET=xxx
# your second step app API key
DM_CONSUMER_KEY=xxx
DM_CONSUMER_SECRET=xxx # your second step app API secret key

# Front-end URL (without any ending /)
WEB_URL=https://unfollow.ninja

# a secret key to sign cookies (ex. generate yours on https://password.new)
COOKIE_SIGNING_KEY=Kg8hfQoGj9GHjdKjsYqPtk6ShJqaoP

# optionally:

# -- The twitter account quoted in the notifications (ex. welcome to @[TWITTER_ACCOUNT]!)
TWITTER_ACCOUNT=unfollowninja

# -- The timezone use for follow time. ex: Europe/Paris
TIMEZONE=UTC 

# -- Default language for the notifications (for new users).
DEFAULT_LANGUAGE=en

# -- Number of workers, defaults to number of CPUs
CLUSTER_SIZE=2
# -- Number of unique tasks managed at the same time, defaults to 15
WORKER_RATE_LIMIT=15

# -- When this twitter user is logged in, it has access to the /admin/user/[username] debug enpoint
ADMIN_USERID=

# -- Sentry (API) DSN, if you want to report workers (or API) errors on sentry
SENTRY_DSN=
SENTRY_DSN_API=

# -- If you set these variables, the server will send some metrics to these statsd / datadog servers
STATSD_HOST=
DD_AGENT_HOST=
# -- The metrics will start with this prefix (ex metric: [prefix].check-duration.worker.[cluster-nb])
METRICS_PREFIX=uninja
```

You can also set these parameters as environment variables.

## Contribute

Open an issue with your suggestions or assign yourself to an existing issue

### Translate the app to your language

You can help to translate the app on https://hosted.weblate.org/projects/unfollow-monkey/notifications/

Weblate will automatically open a PR to update or add the language in this repository.

## Motivation behind improving the legacy version

Legacy version: https://github.com/PLhery/unfollowNinja/tree/legacy

The legacy version couldn't scale and manage thousands of users, while still checking every 2 minutes the followers.
Now, the program checks 100 000 users's followers in about 3 minutes with 12 cpus.

- Based on a job queue for:
    - Monitoring: I can see how many jobs/sec are happening, their errors, and rate limit them.
    - Scalability: Everything was happening in one thread. Now the work is shared between 8 workers/vCPUs.
    - Atomicity: When the server restart, wait for every atomic job to finish instead of interrupting them. (causing messages not sent or sent twice)
    - Reliability: If a job fails, prepare better retry strategies.
- Use Typescript to make code more bug-proof and clearer for contributors
- Split the webserver from the worker: When the worker was busy, the webserver was slow because everything was happening in the same thread.
- Use redis to store the list of followers as we need to read/write them really often/quickly
- I18n
- Use twitter's SnowFlake IDs to get the exact follow time
- New UI

# Licensing

[License](./license.md) (Apache V2)

UnfollowNinja uses multiple open-source projects:

#### Twemoji

Copyright 2020 Twitter, Inc and other contributors  
Code licensed under the MIT License: http://opensource.org/licenses/MIT  
Graphics licensed under CC-BY 4.0: https://creativecommons.org/licenses/by/4.0/

================================================
FILE: unfollow-monkey-ui/.gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js

# testing
/coverage

# production
/build

# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local

npm-debug.log*
yarn-debug.log*
yarn-error.log*


================================================
FILE: unfollow-monkey-ui/package.json
================================================
{
  "name": "unfollow-ninja-ui",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@sentry/browser": "^7.3.0",
    "grommet": "^2.17.5",
    "grommet-icons": "^4.6.2",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-dom-confetti": "^0.2.0",
    "react-github-corner": "^2.5.0",
    "react-scripts": "^5.0.0",
    "react-snap": "^1.23.0",
    "sass": "^1.49.8",
    "styled-components": "^5.3.1",
    "web-vitals": "^2.1.0"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "postbuild": "react-snap",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": "react-app"
  },
  "browserslist": {
    "production": [
      ">1%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  }
}


================================================
FILE: unfollow-monkey-ui/public/_redirects
================================================
/1 /index.html 200
/2 /index.html 200

================================================
FILE: unfollow-monkey-ui/public/favicon/site.webmanifest
================================================
{
    "name": "Unfollow Monkey",
    "short_name": "Unfollow Monkey",
    "icons": [
        {
            "src": "/android-chrome-192x192.png",
            "sizes": "192x192",
            "type": "image/png"
        },
        {
            "src": "/android-chrome-512x512.png",
            "sizes": "512x512",
            "type": "image/png"
        }
    ],
    "theme_color": "#b742a0",
    "background_color": "#ffffff",
    "display": "standalone"
}


================================================
FILE: unfollow-monkey-ui/public/index.html
================================================
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />

    <!-- Icons -->
    <link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/favicon/apple-touch-icon.png">
    <link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon/favicon-32x32.png">
    <link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon/favicon-16x16.png">
    <link rel="manifest" href="%PUBLIC_URL%/favicon/site.webmanifest">
    <meta name="theme-color" content="#b742a0">

    <!-- Primary Meta Tags -->
    <title>Unfollow Monkey</title>
    <meta name="title" content="Unfollow Monkey">
    <meta name="description" content="Get notified when your Twitter account loses a follower">

    <!-- Open Graph / Facebook -->
    <meta property="og:type" content="website">
    <meta property="og:url" content="https://unfollow-monkey.com/">
    <meta property="og:title" content="Unfollow Monkey">
    <meta property="og:description" content="Get notified when your Twitter account loses a follower">
    <meta property="og:image" content="https://unfollow-monkey.com/preview.png">

    <!-- Twitter -->
    <meta property="twitter:card" content="summary_large_image">
    <meta property="twitter:url" content="https://unfollow-monkey.com/">
    <meta property="twitter:title" content="Unfollow Monkey">
    <meta property="twitter:description" content="Get notified when your Twitter account loses a follower">
    <meta property="twitter:image" content="https://unfollow-monkey.com/preview.png">

    <link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600&family=Quicksand:wght@400;600&display=swap" rel="stylesheet">
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
  </body>
</html>


================================================
FILE: unfollow-monkey-ui/public/manifest.json
================================================
{
  "short_name": "Unfollow Monkey",
  "name": "Unfollow Monkey",
  "icons": [
    {
      "src": "favicon.ico",
      "sizes": "64x64 32x32 24x24 16x16",
      "type": "image/x-icon"
    }
  ],
  "start_url": ".",
  "display": "standalone",
  "theme_color": "#b742a0",
  "background_color": "#ffffff"
}


================================================
FILE: unfollow-monkey-ui/public/privacy-policy.txt
================================================
PRIVACY POLICY - UNFOLLOW MONKEY - https://unfollow-monkey.com
--------------------------------------------------------------
Effective 12/12/2021

At Unfollow Monkey, we take privacy and security seriously. This Privacy Policy outlines how Unfollow Monkey (collectively, Unfollow Monkey,” “we,” “our,” or “us”) process the information we collect about you through our website and other online services (collectively, the “Services”) and when you otherwise interact with us, such as through our customer service channels.

If you are a California resident, please also see the "CCPA PRIVACY RIGHTS" section below.

1. INFORMATION WE COLLECT AND HOW WE COLLECT IT

Information You Provide
-----------------------
We collect information you provide when you use our Services or otherwise engage or communicate with us as described below.

Identity Data, such as your name, when you buy a subscription
Contact Data, such as your email address, and mailing address, when you buy a subscription
Profile Data, such as your Twitter profile and authentication token
Additional Data You Provide, such as via survey responses, contests/sweepstakes, customer support, or other means.

Information We Collect Automatically
-----------------------
As is true of many digital platforms, we also collect certain information about you automatically when you use our Services, as described below.

Usage Information. We collect information about your activity on our Services, which includes device identifiers (like IP address or mobile device identifiers), pages or features you use, time and date of access, and other similar usage information.

Transactional Information.
When you receive, submit, or complete a transaction via the Services, we collect information about the transaction, such as transaction amount, type and nature of the transaction, and time and date of the transaction.

Information Collected Through Tracking Technologies.
We and our service providers also use technologies, including cookies and web beacons, to automatically collect certain types of usage and device information when you use our Services or interact with our emails. The information collected through these technologies includes your IP address, browser type, Internet service provider, platform type, device type, operating system, date and time stamp, a unique device or account ID, usage information and other similar information. For information about how to disable cookies, please see the Your Controls section below.

Information We Collect from Other Sources
-----------------------
We also obtain information about you from other sources as described below.

Connected Services. When you connect your account to Twitter, Twitter may send us information such as your profile information from that service. This information varies and is controlled by that service or as authorized by you via your privacy settings at that service.

2. HOW WE USE YOUR INFORMATION.
We use the information we collect for purposes described below or as otherwise described to you at the point of collection:

Maintain and provide the Services, including to process account applications, authenticate your identity, repair our Services, and handle billing and account management;
Send you transactional or relationship information, including confirmations, invoices, technical notices, customer support responses, software updates, security alerts, support and administrative messages, and information about your transactions;
Send you notifications about who stopped following you on Twitter
Monitor and improve our Services, including analyzing usage, research and development;
Help protect the safety and security of our Services, business, and users, such as to investigate and help prevent fraud or other unlawful activity;
Protect or exercise our legal rights or defend against legal claims, including to enforce and carry out contracts and agreements; and
Comply with applicable laws and legal obligations, such as compliance obligations associated with being a regulated broker-dealer.

3. DISCLOSURES OF INFORMATION.
We are committed to maintaining your trust, and we want you to understand when and with whom we share information about you. We share information about you in the instances described below.

Authorized third-party vendors and service providers. We share information about you with third-party vendors and service providers who perform services for us, such as mailing services, tax and accounting services, web hosting, customer support, and analytics services. 

Legal purposes. We disclose information about you if we believe that disclosure is in accordance with, or required by, any applicable law or legal process or to protect and defend the rights, interests, safety, and security of Unfollow Monkey, our users, or the public.

With your consent. We share information about you for any other purposes disclosed to you with your consent.
We share information with others in an aggregated or otherwise de-identified form that does not reasonably identify you.

4. THIRD-PARTY TRACKING
We use third-party analytics services to better understand your online activity and serve you targeted advertisements. For example, we use Google Analytics and you can review the “How Google uses information from sites or apps that use our services” linked here: http://www.google.com/policies/privacy/partners/ for information about how Google processes the information it collects. These companies collect information about your use of our Services and other websites and online services over time through cookies, device identifiers, or other tracking technologies. The information collected includes your IP address, web browser, mobile network information, pages viewed, time spent, links clicked, and conversion information. We and our third-party partners use this information to, among other things, analyze and track data, and determine the popularity of content.

5. DO NOT TRACK.
Some web browsers transmit “do-not-track” signals to websites. We currently don’t take action in response to these signals.

6. YOUR CONTROLS.
Account profile. You may update certain account profile information by logging into your account.

How to control your communications preferences.
You can stop receiving promotional emails from us by clicking the “unsubscribe” link in those emails. We may still send you service-related or other non-promotional communications, such as account notifications, receipts, security notices and other transactional or relationship messages.

Cookie controls. Many web browsers are set to accept cookies and similar tracking technologies by default. If you prefer, you can set your browser to delete or reject these technologies. If you choose to delete or reject these technologies, this could affect certain features of our Services. Furthermore, if you use a different device, change browsers, or delete the opt-out cookies that contain your preferences, you may need to perform the opt-out task again.

7. CCPA PRIVACY RIGHTS (Do Not Sell My Personal Information)
Under the CCPA, among other rights, California consumers have the right to:

Request that a business that collects a consumer's personal data disclose the categories and specific pieces of personal data that a business has collected about consumers.

Request that a business delete any personal data about the consumer that a business has collected.

Request that a business that sells a consumer's personal data, not sell the consumer's personal data.

If you make a request, we have one month to respond to you. If you would like to exercise any of these rights, please contact us.

8. GDPR DATA PROTECTION RIGHTS
We would like to make sure you are fully aware of all of your data protection rights. Every user is entitled to the following:

The right to access – You have the right to request copies of your personal data. We may charge you a small fee for this service.

The right to rectification – You have the right to request that we correct any information you believe is inaccurate. You also have the right to request that we complete the information you believe is incomplete.

The right to erasure – You have the right to request that we erase your personal data, under certain conditions.

The right to restrict processing – You have the right to request that we restrict the processing of your personal data, under certain conditions.

The right to object to processing – You have the right to object to our processing of your personal data, under certain conditions.

The right to data portability – You have the right to request that we transfer the data that we have collected to another organization, or directly to you, under certain conditions.

If you make a request, we have one month to respond to you. If you would like to exercise any of these rights, please contact us.

8. CHILDREN.
We do not knowingly collect or solicit any information from anyone under the age of 13 on these Services. In the event we learn that we have inadvertently collected personal information from a child under age 13, we will take reasonable steps to delete that information. If you believe that we might have any information from a child under 13, please contact us at help (at) unfollow-monkey.com.

9. TRANSFER OF INFORMATION.
Our Services are currently designed for use only in the United States. If you are using our Services from another jurisdiction, your information may be processed in the United States or other countries that may not provide levels of data protection that are equivalent to those of your home jurisdiction.

10. CHANGES TO THIS POLICY.
This Privacy Policy will evolve with time, and when we update it, we will revise the "Effective Date" above and post the new Policy and, in some cases, we provide additional notice (such as adding a statement to our website or sending you a notification). To stay informed of our privacy practices, we recommend you review the Policy on a regular basis as you continue to use our Services.

11. HOW TO CONTACT US.
If you have any questions about this Privacy Policy, please contact us at help (at) unfollow-monkey.com

================================================
FILE: unfollow-monkey-ui/public/robots.txt
================================================
User-agent: *
Sitemap: https://unfollow-monkey.com/sitemap.xml

================================================
FILE: unfollow-monkey-ui/public/sitemap.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
    <url>
        <loc>https://unfollow-monkey.com/</loc>
    </url>
</urlset>

================================================
FILE: unfollow-monkey-ui/public/tos.txt
================================================
TERMS OF SERVICE - UNFOLLOW MONKEY - https://unfollow-monkey.com
--------------------------------------------------------------
Last updated 12/12/2021

FRENCH LEGAL TERMS

L’édition et la direction de la publication du site https://unfollow-monkey.com/ est assurée par Paul-Louis HERY, domicilié au 30 rue de Chazelles, 75017 Paris, France.
Numero de telephone: 0680489983
Adresse email: help (at) unfollow-monkey.com

L'hébergeur du site https://unfollow-monkey.com/ est la société PulseHeberg, dont le siège social est situé au 55 AVENUE DES CHAMPS PIERREUX, 92000 NANTERRE, avec pour adresse email contact (at) pulseheberg.com.

AGREEMENT TO TERMS

These Terms of Service constitute a legally binding agreement made between you, whether personally or on behalf of an entity (“you”) and Unfollow Monkey (“we,” “us” or “our”), concerning your access to and use of the unfollow-monkey.com website as well as any other media form, media channel, mobile website or mobile application related, linked, or otherwise connected thereto (collectively, the “Site”).

You agree that by accessing the Site, you have read, understood, and agree to be bound by all of these Terms of Service. If you do not agree with all of these Terms of Service, then you are expressly prohibited from using the Site and you must discontinue use immediately.

Supplemental Terms of Service or documents that may be posted on the Site from time to time are hereby expressly incorporated herein by reference. We reserve the right, in our sole discretion, to make changes or modifications to these Terms of Service at any time and for any reason.

We will alert you about any changes by updating the “Last updated” date of these Terms of Service, and you waive any right to receive specific notice of each such change.

It is your responsibility to periodically review these Terms of Service to stay informed of updates. You will be subject to, and will be deemed to have been made aware of and to have accepted, the changes in any revised Terms of Service by your continued use of the Site after the date such revised Terms of Service are posted.

The information provided on the Site is not intended for distribution to or use by any person or entity in any jurisdiction or country where such distribution or use would be contrary to law or regulation or which would subject us to any registration requirement within such jurisdiction or country.

Accordingly, those persons who choose to access the Site from other locations do so on their own initiative and are solely responsible for compliance with local laws, if and to the extent local laws are applicable.

These Terms of Service were generated by Termly’s Terms and Conditions Generator.

The Site is intended for users who are at least 13 years of age. All users who are minors in the jurisdiction in which they reside (generally under the age of 18) must have the permission of, and be directly supervised by, their parent or guardian to use the Site. If you are a minor, you must have your parent or guardian read and agree to these Terms of Service prior to you using the Site.

INTELLECTUAL PROPERTY RIGHTS

Unless otherwise indicated, the Site is our proprietary property and all source code, databases, functionality, software, website designs, audio, video, text, photographs, and graphics on the Site (collectively, the “Content”) and the trademarks, service marks, and logos contained therein (the “Marks”) are owned or controlled by us or licensed to us, and are protected by copyright and trademark laws and various other intellectual property rights and unfair competition laws of the United States, foreign jurisdictions, and international conventions.

The Content and the Marks are provided on the Site “AS IS” for your information and personal use only. Except as expressly provided in these Terms of Service, no part of the Site and no Content or Marks may be copied, reproduced, aggregated, republished, uploaded, posted, publicly displayed, encoded, translated, transmitted, distributed, sold, licensed, or otherwise exploited for any commercial purpose whatsoever, without our express prior written permission.

Provided that you are eligible to use the Site, you are granted a limited license to access and use the Site and to download or print a copy of any portion of the Content to which you have properly gained access solely for your personal, non-commercial use. We reserve all rights not expressly granted to you in and to the Site, the Content and the Marks.

USER REPRESENTATIONS

By using the Site, you represent and warrant that:

(1) all registration information you submit will be true, accurate, current, and complete;

(2) you will maintain the accuracy of such information and promptly update such registration information as necessary;

(3) you have the legal capacity and you agree to comply with these Terms of Service;

(4) you are not under the age of 13;

(5) not a minor in the jurisdiction in which you reside, or if a minor, you have received parental permission to use the Site;

(6) you will not access the Site through automated or non-human means, whether through a bot, script, or otherwise;

(7) you will not use the Site for any illegal or unauthorized purpose;

(8) your use of the Site will not violate any applicable law or regulation.

If you provide any information that is untrue, inaccurate, not current, or incomplete, we have the right to suspend or terminate your account and refuse any and all current or future use of the Site (or any portion thereof).

USER REGISTRATION

You may be required to register with the Site. You agree to keep your password confidential and will be responsible for all use of your account and password. We reserve the right to remove, reclaim, or change a username you select if we determine, in our sole discretion, that such username is inappropriate, obscene, or otherwise objectionable.

PROHIBITED ACTIVITIES

You may not access or use the Site for any purpose other than that for which we make the Site available. The Site may not be used in connection with any commercial endeavors except those that are specifically endorsed or approved by us.

As a user of the Site, you agree not to:

systematically retrieve data or other content from the Site to create or compile, directly or indirectly, a collection, compilation, database, or directory without written permission from us.
make any unauthorized use of the Site, including collecting usernames and/or email addresses of users by electronic or other means for the purpose of sending unsolicited email, or creating user accounts by automated means or under false pretenses.
use a buying agent or purchasing agent to make purchases on the Site.
use the Site to advertise or offer to sell goods and services.
circumvent, disable, or otherwise interfere with security-related features of the Site, including features that prevent or restrict the use or copying of any Content or enforce limitations on the use of the Site and/or the Content contained therein.
engage in unauthorized framing of or linking to the Site.
trick, defraud, or mislead us and other users, especially in any attempt to learn sensitive account information such as user passwords;
make improper use of our support services or submit false reports of abuse or misconduct.
engage in any automated use of the system, such as using scripts to send comments or messages, or using any data mining, robots, or similar data gathering and extraction tools.
interfere with, disrupt, or create an undue burden on the Site or the networks or services connected to the Site.
attempt to impersonate another user or person or use the username of another user.
sell or otherwise transfer your profile.
use any information obtained from the Site in order to harass, abuse, or harm another person.
use the Site as part of any effort to compete with us or otherwise use the Site and/or the Content for any revenue-generating endeavor or commercial enterprise.
decipher, decompile, disassemble, or reverse engineer any of the software comprising or in any way making up a part of the Site.
attempt to bypass any measures of the Site designed to prevent or restrict access to the Site, or any portion of the Site.
harass, annoy, intimidate, or threaten any of our employees or agents engaged in providing any portion of the Site to you.
delete the copyright or other proprietary rights notice from any Content.
copy or adapt the Site’s software, including but not limited to Flash, PHP, HTML, JavaScript, or other code.
upload or transmit (or attempt to upload or to transmit) viruses, Trojan horses, or other material, including excessive use of capital letters and spamming (continuous posting of repetitive text), that interferes with any party’s uninterrupted use and enjoyment of the Site or modifies, impairs, disrupts, alters, or interferes with the use, features, functions, operation, or maintenance of the Site.
upload or transmit (or attempt to upload or to transmit) any material that acts as a passive or active information collection or transmission mechanism, including without limitation, clear graphics interchange formats (“gifs”), 1×1 pixels, web bugs, cookies, or other similar devices (sometimes referred to as “spyware” or “passive collection mechanisms” or “pcms”).
except as may be the result of standard search engine or Internet browser usage, use, launch, develop, or distribute any automated system, including without limitation, any spider, robot, cheat utility, scraper, or offline reader that accesses the Site, or using or launching any unauthorized script or other software.
disparage, tarnish, or otherwise harm, in our opinion, us and/or the Site.
use the Site in a manner inconsistent with any applicable laws or regulations.

USER GENERATED CONTRIBUTIONS

The Site may invite you to chat, contribute to, or participate in blogs, message boards, online forums, and other functionality, and may provide you with the opportunity to create, submit, post, display, transmit, perform, publish, distribute, or broadcast content and materials to us or on the Site, including but not limited to text, writings, video, audio, photographs, graphics, comments, suggestions, or personal information or other material (collectively, “Contributions”).

Contributions may be viewable by other users of the Site and through third-party websites. As such, any Contributions you transmit may be treated as non-confidential and non-proprietary. When you create or make available any Contributions, you thereby represent and warrant that:

the creation, distribution, transmission, public display, or performance, and the accessing, downloading, or copying of your Contributions do not and will not infringe the proprietary rights, including but not limited to the copyright, patent, trademark, trade secret, or moral rights of any third party.
you are the creator and owner of or have the necessary licenses, rights, consents, releases, and permissions to use and to authorize us, the Site, and other users of the Site to use your Contributions in any manner contemplated by the Site and these Terms of Service.
you have the written consent, release, and/or permission of each and every identifiable individual person in your Contributions to use the name or likeness of each and every such identifiable individual person to enable inclusion and use of your Contributions in any manner contemplated by the Site and these Terms of Service.
your Contributions are not false, inaccurate, or misleading.
your Contributions are not unsolicited or unauthorized advertising, promotional materials, pyramid schemes, chain letters, spam, mass mailings, or other forms of solicitation.
your Contributions are not obscene, lewd, lascivious, filthy, violent, harassing, libelous, slanderous, or otherwise objectionable (as determined by us).
your Contributions do not ridicule, mock, disparage, intimidate, or abuse anyone.
your Contributions do not advocate the violent overthrow of any government or incite, encourage, or threaten physical harm against another.
your Contributions do not violate any applicable law, regulation, or rule.
your Contributions do not violate the privacy or publicity rights of any third party.
your Contributions do not contain any material that solicits personal information from anyone under the age of 18 or exploits people under the age of 18 in a sexual or violent manner.
your Contributions do not violate any federal or state law concerning child pornography, or otherwise intended to protect the health or well-being of minors;
your Contributions do not include any offensive comments that are connected to race, national origin, gender, sexual preference, or physical handicap.
your Contributions do not otherwise violate, or link to material that violates, any provision of these Terms of Service, or any applicable law or regulation.
Any use of the Site in violation of the foregoing violates these Terms of Service and may result in, among other things, termination or suspension of your rights to use the Site.

CONTRIBUTION LICENSE

By posting your Contributions to any part of the Site, or making Contributions accessible to the Site by linking your account from the Site to any of your social networking accounts, you automatically grant, and you represent and warrant that you have the right to grant, to us an unrestricted, unlimited, irrevocable, perpetual, non-exclusive, transferable, royalty-free, fully-paid, worldwide right, and license to host, use, copy, reproduce, disclose, sell, resell, publish, broadcast, retitle, archive, store, cache, publicly perform, publicly display, reformat, translate, transmit, excerpt (in whole or in part), and distribute such Contributions (including, without limitation, your image and voice) for any purpose, commercial, advertising, or otherwise, and to prepare derivative works of, or incorporate into other works, such Contributions, and grant and authorize sublicenses of the foregoing. The use and distribution may occur in any media formats and through any media channels.

This license will apply to any form, media, or technology now known or hereafter developed, and includes our use of your name, company name, and franchise name, as applicable, and any of the trademarks, service marks, trade names, logos, and personal and commercial images you provide. You waive all moral rights in your Contributions, and you warrant that moral rights have not otherwise been asserted in your Contributions.

We do not assert any ownership over your Contributions. You retain full ownership of all of your Contributions and any intellectual property rights or other proprietary rights associated with your Contributions. We are not liable for any statements or representations in your Contributions provided by you in any area on the Site.

You are solely responsible for your Contributions to the Site and you expressly agree to exonerate us from any and all responsibility and to refrain from any legal action against us regarding your Contributions.

We have the right, in our sole and absolute discretion, (1) to edit, redact, or otherwise change any Contributions; (2) to re-categorize any Contributions to place them in more appropriate locations on the Site; and (3) to pre-screen or delete any Contributions at any time and for any reason, without notice. We have no obligation to monitor your Contributions.

SOCIAL MEDIA

As part of the functionality of the Site, you may link your account with online accounts you have with third-party service providers (each such account, a “Third-Party Account”) by either: (1) providing your Third-Party Account login information through the Site; or (2) allowing us to access your Third-Party Account, as is permitted under the applicable Terms of Service that govern your use of each Third-Party Account.

You represent and warrant that you are entitled to disclose your Third-Party Account login information to us and/or grant us access to your Third-Party Account, without breach by you of any of the Terms of Service that govern your use of the applicable Third-Party Account, and without obligating us to pay any fees or making us subject to any usage limitations imposed by the third-party service provider of the Third-Party Account.

By granting us access to any Third-Party Accounts, you understand that (1) we may access, make available, and store (if applicable) any content that you have provided to and stored in your Third-Party Account (the “Social Network Content”) so that it is available on and through the Site via your account, including without limitation any friend lists and (2) we may submit to and receive from your Third-Party Account additional information to the extent you are notified when you link your account with the Third-Party Account.

Depending on the Third-Party Accounts you choose and subject to the privacy settings that you have set in such Third-Party Accounts, personally identifiable information that you post to your Third-Party Accounts may be available on and through your account on the Site.

Please note that if a Third-Party Account or associated service becomes unavailable or our access to such Third-Party Account is terminated by the third-party service provider, then Social Network Content may no longer be available on and through the Site. You will have the ability to disable the connection between your account on the Site and your Third-Party Accounts at any time.

PLEASE NOTE THAT YOUR RELATIONSHIP WITH THE THIRD-PARTY SERVICE PROVIDERS ASSOCIATED WITH YOUR THIRD-PARTY ACCOUNTS IS GOVERNED SOLELY BY YOUR AGREEMENT(S) WITH SUCH THIRD-PARTY SERVICE PROVIDERS.

We make no effort to review any Social Network Content for any purpose, including but not limited to, for accuracy, legality, or non-infringement, and we are not responsible for any Social Network Content.

You acknowledge and agree that we may access your email address book associated with a Third-Party Account and your contacts list stored on your mobile device or tablet computer solely for purposes of identifying and informing you of those contacts who have also registered to use the Site.

You can deactivate the connection between the Site and your Third-Party Account by contacting us using the contact information below or through your account settings (if applicable). We will attempt to delete any information stored on our servers that was obtained through such Third-Party Account, except the username and profile picture that become associated with your account.

SUBMISSIONS

You acknowledge and agree that any questions, comments, suggestions, ideas, feedback, or other information regarding the Site (“Submissions”) provided by you to us are non-confidential and shall become our sole property. We shall own exclusive rights, including all intellectual property rights, and shall be entitled to the unrestricted use and dissemination of these Submissions for any lawful purpose, commercial or otherwise, without acknowledgment or compensation to you.

You hereby waive all moral rights to any such Submissions, and you hereby warrant that any such Submissions are original with you or that you have the right to submit such Submissions. You agree there shall be no recourse against us for any alleged or actual infringement or misappropriation of any proprietary right in your Submissions.

THIRD-PARTY WEBSITES AND CONTENT

The Site may contain (or you may be sent via the Site) links to other websites (“Third-Party Websites”) as well as articles, photographs, text, graphics, pictures, designs, music, sound, video, information, applications, software, and other content or items belonging to or originating from third parties (“Third-Party Content”).

Such Third-Party Websites and Third-Party Content are not investigated, monitored, or checked for accuracy, appropriateness, or completeness by us, and we are not responsible for any Third-Party Websites accessed through the Site or any Third-Party Content posted on, available through, or installed from the Site, including the content, accuracy, offensiveness, opinions, reliability, privacy practices, or other policies of or contained in the Third-Party Websites or the Third-Party Content.

Inclusion of, linking to, or permitting the use or installation of any Third-Party Websites or any Third-Party Content does not imply approval or endorsement thereof by us. If you decide to leave the Site and access the Third-Party Websites or to use or install any Third-Party Content, you do so at your own risk, and you should be aware these Terms of Service no longer govern.

You should review the applicable terms and policies, including privacy and data gathering practices, of any website to which you navigate from the Site or relating to any applications you use or install from the Site. Any purchases you make through Third-Party Websites will be through other websites and from other companies, and we take no responsibility whatsoever in relation to such purchases which are exclusively between you and the applicable third party.

You agree and acknowledge that we do not endorse the products or services offered on Third-Party Websites and you shall hold us harmless from any harm caused by your purchase of such products or services. Additionally, you shall hold us harmless from any losses sustained by you or harm caused to you relating to or resulting in any way from any Third-Party Content or any contact with Third-Party Websites.

SITE MANAGEMENT

We reserve the right, but not the obligation, to:

(1) monitor the Site for violations of these Terms of Service;

(2) take appropriate legal action against anyone who, in our sole discretion, violates the law or these Terms of Service, including without limitation, reporting such user to law enforcement authorities;

(3) in our sole discretion and without limitation, refuse, restrict access to, limit the availability of, or disable (to the extent technologically feasible) any of your Contributions or any portion thereof;

(4) in our sole discretion and without limitation, notice, or liability, to remove from the Site or otherwise disable all files and content that are excessive in size or are in any way burdensome to our systems;

(5) otherwise manage the Site in a manner designed to protect our rights and property and to facilitate the proper functioning of the Site.

PRIVACY POLICY

We care about data privacy and security. Please review our Privacy Policy https://unfollow-monkey.com/privacy-policy.txt.
By using the Site, you agree to be bound by our Privacy Policy, which is incorporated into these Terms of Service. Please be advised the Site is hosted in France.

If you access the Site from any region of the world with laws or other requirements governing personal data collection, use, or disclosure that differ from applicable laws in France, then through your continued use of the Site, you are transferring your data to France, and you expressly consent to have your data transferred to and processed in France.

Further, we do not knowingly accept, request, or solicit information from children or knowingly market to children. Therefore, in accordance with the U.S. Children’s Online Privacy Protection Act, if we receive actual knowledge that anyone under the age of 13 has provided personal information to us without the requisite and verifiable parental consent, we will delete that information from the Site as quickly as is reasonably practical.

COPYRIGHT INFRINGEMENTS

We respect the intellectual property rights of others. If you believe that any material available on or through the Site infringes upon any copyright you own or control, please immediately notify us using the contact information provided below (a “Notification”). A copy of your Notification will be sent to the person who posted or stored the material addressed in the Notification.

Please be advised that pursuant to federal law you may be held liable for damages if you make material misrepresentations in a Notification. Thus, if you are not sure that material located on or linked to by the Site infringes your copyright, you should consider first contacting an attorney.

TERM AND TERMINATION

These Terms of Service shall remain in full force and effect while you use the Site. WITHOUT LIMITING ANY OTHER PROVISION OF THESE TERMS OF SERVICE, WE RESERVE THE RIGHT TO, IN OUR SOLE DISCRETION AND WITHOUT NOTICE OR LIABILITY, DENY ACCESS TO AND USE OF THE SITE (INCLUDING BLOCKING CERTAIN IP ADDRESSES), TO ANY PERSON FOR ANY REASON OR FOR NO REASON, INCLUDING WITHOUT LIMITATION FOR BREACH OF ANY REPRESENTATION, WARRANTY, OR COVENANT CONTAINED IN THESE TERMS OF SERVICE OR OF ANY APPLICABLE LAW OR REGULATION. WE MAY TERMINATE YOUR USE OR PARTICIPATION IN THE SITE OR DELETE YOUR ACCOUNT AND ANY CONTENT OR INFORMATION THAT YOU POSTED AT ANY TIME, WITHOUT WARNING, IN OUR SOLE DISCRETION.

If we terminate or suspend your account for any reason, you are prohibited from registering and creating a new account under your name, a fake or borrowed name, or the name of any third party, even if you may be acting on behalf of the third party.

In addition to terminating or suspending your account, we reserve the right to take appropriate legal action, including without limitation pursuing civil, criminal, and injunctive redress.

MODIFICATIONS AND INTERRUPTIONS

We reserve the right to change, modify, or remove the contents of the Site at any time or for any reason at our sole discretion without notice. However, we have no obligation to update any information on our Site. We also reserve the right to modify or discontinue all or part of the Site without notice at any time.

We will not be liable to you or any third party for any modification, price change, suspension, or discontinuance of the Site.

We cannot guarantee the Site will be available at all times. We may experience hardware, software, or other problems or need to perform maintenance related to the Site, resulting in interruptions, delays, or errors.

We reserve the right to change, revise, update, suspend, discontinue, or otherwise modify the Site at any time or for any reason without notice to you. You agree that we have no liability whatsoever for any loss, damage, or inconvenience caused by your inability to access or use the Site during any downtime or discontinuance of the Site.

Nothing in these Terms of Service will be construed to obligate us to maintain and support the Site or to supply any corrections, updates, or releases in connection therewith.

GOVERNING LAW

These terms and conditions are governed by and construed in accordance with the laws of France and you irrevocably submit to the exclusive jurisdiction of the courts in that State or location.

DISPUTE RESOLUTION

Option 1: Any legal action of whatever nature brought by either you or us (collectively, the “Parties” and individually, a “Party”) shall be commenced or prosecuted in the courts located in France, and the Parties hereby consent to, and waive all defenses of lack of personal jurisdiction and forum non conveniens with respect to venue and jurisdiction in such state and federal courts.

CORRECTIONS

There may be information on the Site that contains typographical errors, inaccuracies, or omissions that may relate to the Site, including descriptions, pricing, availability, and various other information. We reserve the right to correct any errors, inaccuracies, or omissions and to change or update the information on the Site at any time, without prior notice.

DISCLAIMER

THE SITE IS PROVIDED ON AN AS-IS AND AS-AVAILABLE BASIS. YOU AGREE THAT YOUR USE OF THE SITE AND OUR SERVICES WILL BE AT YOUR SOLE RISK. TO THE FULLEST EXTENT PERMITTED BY LAW, WE DISCLAIM ALL WARRANTIES, EXPRESS OR IMPLIED, IN CONNECTION WITH THE SITE AND YOUR USE THEREOF, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. WE MAKE NO WARRANTIES OR REPRESENTATIONS ABOUT THE ACCURACY OR COMPLETENESS OF THE SITE’S CONTENT OR THE CONTENT OF ANY WEBSITES LINKED TO THE SITE AND WE WILL ASSUME NO LIABILITY OR RESPONSIBILITY FOR ANY (1) ERRORS, MISTAKES, OR INACCURACIES OF CONTENT AND MATERIALS, (2) PERSONAL INJURY OR PROPERTY DAMAGE, OF ANY NATURE WHATSOEVER, RESULTING FROM YOUR ACCESS TO AND USE OF THE SITE, (3) ANY UNAUTHORIZED ACCESS TO OR USE OF OUR SECURE SERVERS AND/OR ANY AND ALL PERSONAL INFORMATION AND/OR FINANCIAL INFORMATION STORED THEREIN, (4) ANY INTERRUPTION OR CESSATION OF TRANSMISSION TO OR FROM THE SITE, (5) ANY BUGS, VIRUSES, TROJAN HORSES, OR THE LIKE WHICH MAY BE TRANSMITTED TO OR THROUGH THE SITE BY ANY THIRD PARTY, AND/OR (6) ANY ERRORS OR OMISSIONS IN ANY CONTENT AND MATERIALS OR FOR ANY LOSS OR DAMAGE OF ANY KIND INCURRED AS A RESULT OF THE USE OF ANY CONTENT POSTED, TRANSMITTED, OR OTHERWISE MADE AVAILABLE VIA THE SITE. WE DO NOT WARRANT, ENDORSE, GUARANTEE, OR ASSUME RESPONSIBILITY FOR ANY PRODUCT OR SERVICE ADVERTISED OR OFFERED BY A THIRD PARTY THROUGH THE SITE, ANY HYPERLINKED WEBSITE, OR ANY WEBSITE OR MOBILE APPLICATION FEATURED IN ANY BANNER OR OTHER ADVERTISING, AND WE WILL NOT BE A PARTY TO OR IN ANY WAY BE RESPONSIBLE FOR MONITORING ANY TRANSACTION BETWEEN YOU AND ANY THIRD-PARTY PROVIDERS OF PRODUCTS OR SERVICES.

AS WITH THE PURCHASE OF A PRODUCT OR SERVICE THROUGH ANY MEDIUM OR IN ANY ENVIRONMENT, YOU SHOULD USE YOUR BEST JUDGMENT AND EXERCISE CAUTION WHERE APPROPRIATE.

LIMITATIONS OF LIABILITY

IN NO EVENT WILL WE BE LIABLE TO YOU OR ANY THIRD PARTY FOR ANY DIRECT, INDIRECT, CONSEQUENTIAL, EXEMPLARY, INCIDENTAL, SPECIAL, OR PUNITIVE DAMAGES, INCLUDING LOST PROFIT, LOST REVENUE, LOSS OF DATA, OR OTHER DAMAGES ARISING FROM YOUR USE OF THE SITE, EVEN IF WE HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.

IF THESE LAWS APPLY TO YOU, SOME OR ALL OF THE ABOVE DISCLAIMERS OR LIMITATIONS MAY NOT APPLY TO YOU, AND YOU MAY HAVE ADDITIONAL RIGHTS.]

INDEMNIFICATION

You agree to defend, indemnify, and hold us harmless, including our subsidiaries, affiliates, and all of our respective officers, agents, partners, and employees, from and against any loss, damage, liability, claim, or demand, including reasonable attorneys’ fees and expenses.

Notwithstanding the foregoing, we reserve the right, at your expense, to assume the exclusive defense and control of any matter for which you are required to indemnify us, and you agree to cooperate, at your expense, with our defense of such claims. We will use reasonable efforts to notify you of any such claim, action, or proceeding which is subject to this indemnification upon becoming aware of it.

USER DATA

We will maintain certain data that you transmit to the Site for the purpose of managing the Site, as well as data relating to your use of the Site. Although we perform regular routine backups of data, you are solely responsible for all data that you transmit or that relates to any activity you have undertaken using the Site.

You agree that we shall have no liability to you for any loss or corruption of any such data, and you hereby waive any right of action against us arising from any such loss or corruption of such data.

ELECTRONIC COMMUNICATIONS, TRANSACTIONS, AND SIGNATURES

Visiting the Site, sending us emails, and completing online forms constitute electronic communications. You consent to receive electronic communications, and you agree that all agreements, notices, disclosures, and other communications we provide to you electronically, via email and on the Site, satisfy any legal requirement that such communication be in writing.

YOU HEREBY AGREE TO THE USE OF ELECTRONIC SIGNATURES, CONTRACTS, ORDERS, AND OTHER RECORDS, AND TO ELECTRONIC DELIVERY OF NOTICES, POLICIES, AND RECORDS OF TRANSACTIONS INITIATED OR COMPLETED BY US OR VIA THE SITE.

You hereby waive any rights or requirements under any statutes, regulations, rules, ordinances, or other laws in any jurisdiction which require an original signature or delivery or retention of non-electronic records, or to payments or the granting of credits by any means other than electronic means.

CALIFORNIA USERS AND RESIDENTS

If any complaint with us is not satisfactorily resolved, you can contact the Complaint Assistance Unit of the Division of Consumer Services of the California Department of Consumer Affairs in writing at 1625 North Market Blvd., Suite N 112, Sacramento, California 95834 or by telephone at (800) 952-5210 or (916) 445-1254.

MISCELLANEOUS

These Terms of Service and any policies or operating rules posted by us on the Site constitute the entire agreement and understanding between you and us. Our failure to exercise or enforce any right or provision of these Terms of Service shall not operate as a waiver of such right or provision.

These Terms of Service operate to the fullest extent permissible by law. We may assign any or all of our rights and obligations to others at any time. We shall not be responsible or liable for any loss, damage, delay, or failure to act caused by any cause beyond our reasonable control.

If any provision or part of a provision of these Terms of Service is determined to be unlawful, void, or unenforceable, that provision or part of the provision is deemed severable from these Terms of Service and does not affect the validity and enforceability of any remaining provisions.

There is no joint venture, partnership, employment or agency relationship created between you and us as a result of these Terms of Service or use of the Site. You agree that these Terms of Service will not be construed against us by virtue of having drafted them.

You hereby waive any and all defenses you may have based on the electronic form of these Terms of Service and the lack of signing by the parties hereto to execute these Terms of Service.

CONTACT US

In order to resolve a complaint regarding the Site or to receive further information regarding use of the Site, please contact us at: help (at) unfollow-monkey.com

================================================
FILE: unfollow-monkey-ui/src/App.js
================================================
import React from 'react';

import './style.scss';

import {Box, Grommet, Heading, Image, Paragraph, Text} from 'grommet';
import GithubCorner from "react-github-corner";

import { Faq, Link, MiniApp, Navbar, Section, Repo }  from "./components";
import * as Images from './images';

const theme = {
  global: {
    font: {
      family: 'Open Sans',
    },
    colors: {
      doc: '#4c4e6e',
      dark: '#1a1b46',
      lightPink: '#ffeeed',
      brand: '#70B7FD',
    },
  },
  heading: {
    font: {
      family: 'Quicksand',
    },
    weight: 700,
  },
  paragraph: {
    large: {
      height: '32px',
    },
    medium: {
      maxWidth: '800px',
    },
  },
};

function App() {
  return (
      <Grommet theme={theme}>
        <Section>
          <Navbar/>
        </Section>
        <Section>
          <Box direction='row' wrap={true} margin={{vertical: 'large'}}>
            <Box basis='medium' flex={true} pad='medium'>
              <Heading level={1} color='dark'>Get notified when your Twitter account loses a follower</Heading>
              <Paragraph size='large'>Unfollow Monkey sends you a direct message as soon as a twitter user unfollows you, blocks you, or leave Twitter, within seconds.</Paragraph>
			  <MiniApp/>
            </Box>
            <Box basis='medium' flex={true} pad='medium'>
              <Image width='100%' title='Example of notification' src={Images.DmScreenshot}/>
            </Box>
          </Box>
        </Section>
        <Section background='lightPink' sloped={true}>
          <Box direction='row' wrap={true} align='center' justify='center'>
            <Box direction='row' basis='300px' flex='shrink' pad='medium' style={{maxWidth: '50vw'}}>
              <Image title='dog playing' fit='contain' src={Images.Dog}/>
            </Box>
            <Box basis='medium' flex={true} pad='medium' >
              <Heading level={2} color='dark'>Unfollow Monkey is free for everyone</Heading>
              <Paragraph>Unfollow Monkey is based on the <Link href='https://github.com/PLhery/unfollowNinja'>open-source</Link> project <Link href='https://unfollow.ninja'>unfollowNinja</Link>, hosted by <Link href='https://pulseheberg.com/'>PulseHeberg</Link> & <Link href='https://startup.ovhcloud.com/fr/'>OVHCloud startup program</Link> and maintained by <Link href='https://twitter.com/plhery'>@plhery</Link>.</Paragraph>
              <Paragraph>Thanks to PulseHeberg for helping the project to remain substainable, efficient, free, supporting 300 000+ users. They also provide <Link href='https://pulseheberg.com/'>great and affordable servers and web hosting solutions</Link>, if you'd like to have a look!</Paragraph>
			  <Paragraph>UnfollowMonkey is powered by the <i>twitter-api-v2</i> node library, by the same author.</Paragraph>
			  <Box gap='small' alignSelf='center' direction='row'>
				<Repo title='unfollowNinja' description='Get notified when your Twitter account loses a follower.' stars={186} forks={22}/>
				<Repo title='node-twitter-api-v2' description='Strongly typed, full-featured, light, versatile yet powerful Twitter API v1.1 and v2 client for Node.js.' stars={723} forks={88}/>
              </Box>
            </Box>
          </Box>
        </Section>
        <Section background={`url(${Images.useAlaska()})`}>
          <Faq/>
        </Section>
        <Section>
          <Box direction='row' align='center' alignSelf='center' gap='small'>
            <Image title='logo' height={30} src={Images.Logo}/>
            <Text size='small' textAlign='center' style={{fontFamily: 'quicksand'}}>
              © 2020 UnfollowMonkey · <Link href='/tos.txt'>TOS</Link> · <Link href='/privacy-policy.txt'>Privacy</Link> ·
              Discover also <Link href='https://unfollow.ninja/?utm_source=unfollowmonkey_footer'><Image title='unfollowNinja' height={21} src={Images.UnfollowNinja}/></Link> UnfollowNinja <Link href='https://uzzy.me/en?utm_source=unfollowmonkey'><Image title='uzzy' src={Images.Uzzy} height={18}/></Link> Uzzy and <Link href='https://affinitweet.com/?utm_source=unfollowmonkey'><Image title='affinitweet' src={Images.Affinitweet} height={18}/></Link> Affinitweet ·
              Made with ♥ by <Link href='https://twitter.com/plhery'>@plhery</Link> · Available on <Link href='https://github.com/PLhery/unfollowNinja'>GitHub</Link>
            </Text>
          </Box>
        </Section>
        <GithubCorner href="https://github.com/PLhery/unfollowNinja" bannerColor="#b742a0"/>
      </Grommet>
  );
}

export default App;


================================================
FILE: unfollow-monkey-ui/src/components/Faq.js
================================================
import React from 'react';
import { Box, Heading, Paragraph } from "grommet/es6";
import Emojis from '../twemojis/Emojis';
import Styles from './Faq.module.scss';

function Faq(props) {
    return <Box alignSelf='center' pad='medium' margin='medium' className={Styles.container} {...props}>
            <Heading level={1} color='dark'>Frequently Asked Questions</Heading>

            <Heading level={3} color='dark'>A friend of mine unfollowed me, but I wasn't told</Heading>
            <Paragraph>To avoid disturbing you too often, several filters apply to the notifications sent.
                To be sure to get the notification, the person must have followed you for 24 hours and unfollow 20 minutes.</Paragraph>

            <Heading level={3} color='dark'>Will you publish tweets without my consent?</Heading>
            <Paragraph>We will never publish a tweet without your agreement! Only the DM account gives permission to send tweets.
                This is due to the way Twitter permissions work: there are only 3 sets of permissions, and we can't ask permission to send DMs without permission to send tweets.
                You can create a separate Twitter account dedicated to sending these messages if you wish.</Paragraph>

            <Heading level={3} color='dark'>Why does step 2 require so many permissions?</Heading>
            <Paragraph>As described above, this is due to the way Twitter permissions work: there are only 3 sets of permissions, and we can't request permission to send DMs without the others.
                We have never extracted these tokens to use them other than in this open-source application.
                You can create a separate Twitter account dedicated to sending these messages if you wish, for more serenity. A possibility of chrome notifications is under consideration :).
            </Paragraph>

            <Heading level={3} color='dark'>What do the different messages and emojis mean?</Heading>
            <ul>
                <li>The following messages speak for themselves:<ul>
                    <li><b>@username</b> unfollowed you <Emojis.WavingHand/></li>
                    <li><b>@username</b> has been suspended <Emojis.SeeNoEvil/></li>
                    <li><b>@username</b> blocked you <Emojis.NoEntry/></li>
                    <li>You have blocked <b>@username</b> <Emojis.Poo/></li>
                </ul></li>
                <li><b>@username</b> has left Twitter <Emojis.SeeNoEvil/> may mean that the person has been suspended for a few minutes, closed their account, or has been removed from Twitter for example because of the age limit.</li>
                <li>The emoji is a broken hear <Emojis.BrokenHeart/> if this person is a mutual, a person that you follow.</li>
                <li>If more than 20 twittos unfollow you in less than two minutes, you will only be informed of the first 20, as well as the total number of lost followers.</li>
                <li>"A twitto has left Twitter <Emojis.SeeNoEvil/>": when the username of the person who closed his account was not saved (may take 48 hours), you don't receive their username, but are informed of this lost follower.</li>
                <li>"This account followed you before you signed up to <b>@unfollowmonkey</b>!" : we can't always find the exact follow date of each unfollower.
                    If we can't find it, we give you the first time we saw it on your account. However, if they were already following you when you subscribed, we can't give you any date.</li>
            </ul>
        </Box>;
}
export default Faq;

================================================
FILE: unfollow-monkey-ui/src/components/Faq.module.scss
================================================
.container {
  background-color: rgba(255, 255, 255, 0.5);
  p, ul {
    text-align: justify;
  }
  h3 {
    margin-bottom: 0;
    max-width: 630px;
  }
  ul {
    max-width: 760px;
  }
}


================================================
FILE: unfollow-monkey-ui/src/components/Link.js
================================================
import React from 'react';

const Link = (props) => (
    <a target='_blank' rel='noopener noreferrer' style={{color: 'inherit', fontWeight: 600}} {...props} >
      {props.children}
    </a>
);
export default Link;


================================================
FILE: unfollow-monkey-ui/src/components/MiniApp/LanguageSelector.js
================================================
import React from 'react';
import {Paragraph, Select} from "grommet";

import Styles from './LanguageSelector.module.scss';

import * as Flags from '../../images/flags'
import Link from "../Link";
import { Link as IconLink } from 'grommet-icons';


function LanguageSelector(props) {
  const LANGUAGES = [
    {label: 'Arabic', code: 'ar'},
	{label: 'Chinese', code: 'zh_Hans'},
	{label: 'Dutch', code: 'nl'},
	{label: 'English', code: 'en'},
	{label: 'French', code: 'fr'},
	{label: 'German', code: 'de'},
	{label: 'Indonesian', code: 'id'},
	{label: 'Polish', code: 'pl'},
  	{label: 'Portuguese', code: 'pt'},
    {label: 'Portug (br)', code: 'pt_BR'},
	{label: 'Spanish', code: 'es'},
	{label: 'Thai', code: 'th'},
	{label: 'Turkish', code: 'tr'},
	{label: 'Ukrainian', code: 'uk'},
	{label: 'Tamazight', code: 'zgh'},
  ];

  const addYours = <span><Link href='https://hosted.weblate.org/projects/unfollow-monkey/notifications/'><IconLink size='small'/> Add yours</Link></span>

  const options = LANGUAGES.map((lang) =>
	<span className={Styles.element}><img alt={'flag-' + lang.code} className={Styles.flag} src={Flags[lang.code]}/> {lang.label}</span>);

  options.push(addYours);

  return <Paragraph>
	Receive your notifications in:
	<span className={Styles.languageSelector}>
		<Select
		  options={options}
		  size='medium'
		  value={options[LANGUAGES.findIndex((lang) => lang.code === props.value)]}
		  onChange={(e) => props.onChange(LANGUAGES[e.selected].code)}
		  disabled={[addYours]}
		>
		{(option) => option}
	  </Select>
	</span>
  </Paragraph>
}

export default LanguageSelector;


================================================
FILE: unfollow-monkey-ui/src/components/MiniApp/LanguageSelector.module.scss
================================================
.languageSelector {
  padding-left: 10px;
}

div[role=menubar] button:disabled {
  opacity: 0.8;

  a {
    svg {
      margin-left: 11px;
      margin-right: 5px;
    }
    text-decoration: none;
    font-weight: inherit!important;
  }
}

.element {
  display: flex;
  align-items: center;

  .flag {
    height: 17px;
    width: 23px;
    margin: 0 5px;
    box-shadow: rgb(204, 204, 204) 1px 1px 3px;
  }
}


================================================
FILE: unfollow-monkey-ui/src/components/MiniApp/ProCard.js
================================================
import React, {useState} from 'react';
import {Card, Paragraph, TextInput} from "grommet";

import Styles from './ProCard.module.scss';
// import {applePay, googlePay, mastercard} from "../../images";
import { API_URL } from "../MiniApp";
import Link from "../Link";

function ProCard(props) {
  const { user, setUserInfo, setHasError } = props;

  const [isWrongCode, setIsWrongCode] = useState(false);

  const codeChanged = (event) => {
	const newCode = event.target.value;
	if (isWrongCode) {
	  setIsWrongCode(false);
	}
	if (newCode.length !== 6) {
	  return;
	}
	fetch(API_URL + '/user/registerFriendCode', {
	  method: 'put',
	  credentials: 'include',
	  headers: { 'Content-Type': 'application/json' },
	  body: JSON.stringify({code: newCode.toUpperCase()})
	})
	  .then(response => {
		console.log(response);
		if (response.ok) {
		  setUserInfo({
			...user,
			isPro: true,
		  });
		} else if (response.status === 404) {
		  setIsWrongCode(true);
	  	} else {
		  return Promise.reject();
		}
	  })
	  .catch((e) => {
		setHasError(true);
	  });
	window.$crisp?.push(['set', 'session:event', [[['tryCode', { code: newCode}]]]]);
  }

  if (user.isPro) {
	return <Card className={Styles.checkoutCard}>
	  <Paragraph>
		Congratulations, you can now enjoy UMonkey <b className={Styles.pro}>pro</b><br/>
		You will be notified in <b>30sec</b> instead of 30min 🚀<br/>
		{user.hasSubscription && <Link href={`${API_URL}/user/manage-subscription`} className={Styles.subscriptionLink}>Manage subscription</Link>}
	  </Paragraph>
	  { user.friendCodes && <>
		<Paragraph margin={{bottom: 'none'}}>You can also share these codes, thx to the <b className={Styles.pro}>friends</b> plan:</Paragraph>
		<ul style={{marginTop: 0}}>
		{
		  user.friendCodes?.map(code =>
			<li><b>{code.code}</b> - {
			  code.friendUsername ?
				<i>used by <b>@{code.friendUsername}</b>{/*<Button plain={true} icon={<Trash size='small'/>}/>*/}</i> :
				<i>not used yet</i>
			}</li>
		  )
		}
		</ul>
	  </>}
	</Card>
  } else {
	return <Card className={Styles.checkoutCard}>
		<Paragraph>
			You currently receive your notifications in <b>1 hour</b><br/>
			Become <b className={Styles.pro}>pro</b> to be notified in <b>30 seconds</b>!<br/>
		</Paragraph>
		<Paragraph>(Not available right now)</Paragraph>
		{/*<Paragraph>Valid on <b>5 Twitter accounts</b> - {user.priceTags.friends}/year</Paragraph>
		<small>10 days trial - easily cancel online</small>
		<Button
		  className={Styles.checkoutButton}
		  href={`${API_URL}/user/buy-friends`}
		  label={<>
		  <Image className={Styles.googlePayIcon} src={googlePay} alt='Google pay'/>
		  <Image height={26} src={applePay} alt='Apple pay'/>
		  <Image height={26} src={mastercard} alt='Mastercard'/>
		  <span>Pay with Stripe</span>
		</>}/>*/}
		<div className={Styles.friendCodeLine + (isWrongCode ? ' ' + Styles.error : '')}>
		  <Paragraph>Use a friend code:</Paragraph><TextInput maxLength={6} onChange={codeChanged}/>
		</div>
	</Card>
  }

}

export default ProCard;

================================================
FILE: unfollow-monkey-ui/src/components/MiniApp/ProCard.module.scss
================================================
.pro {
  color: #b742a0;
}

.googlePayIcon {
  height: 26px;
  margin-right: 5px;
}

.checkoutCard {
  display: flex;
  flex-direction: column;
  align-items: center;
}

.checkoutButton {
  padding-left: 5px!important;
  margin-top: 15px!important;
}

.subscriptionLink {
  font-size: 15px;
}

.friendCodeLine {
  p{
    display: inline-block;
    margin-right: 10px;
  }
  div { // input container
    display: inline;
  }
  input {
    padding: 3px;
    width: 5em;
  }
}

.friendCodeLine.error {
  color: red;
  input {
    border-color: red;
  }
}

================================================
FILE: unfollow-monkey-ui/src/components/MiniApp.js
================================================
import React, {useEffect, useState} from 'react';
import {
	Accordion,
	AccordionPanel,
	Box,
	Button,
	Paragraph,
	Spinner,
	Table,
	TableBody,
	TableCell, TableHeader,
	TableRow
} from "grommet";
import {Alert, Twitter, ChatOption, UserExpert, Validate} from "grommet-icons";
import Confetti from 'react-dom-confetti';

import Styles from './MiniApp.module.scss';
import Link from "./Link";
import LanguageSelector from "./MiniApp/LanguageSelector";
import ProCard from "./MiniApp/ProCard";

export const API_URL = 'https://api.unfollow-monkey.com';

const LoggedInIntro = ({ user, logout, removeDMs, changeLang, setUserInfo, setHasError }) => {
    if (!user?.username) return null; // not logged in

    let message = <Paragraph>One more step to activate the service:<br/> Choose an account to send you notifications</Paragraph>;
	if (user.category === 2) { // revoked tokens
	  	message = <Paragraph>It seems that your Twitter account has been deactivated:<br/> click on "Enable DM notifications" to reactivate the service.</Paragraph>;
	}
    if (user.dmUsername) {
        message = <Paragraph>All clear! Don't forget to follow <Link href='https://twitter.com/unfollowmonkey' source='logged-in-intro'>@unfollowMonkey</Link></Paragraph>;
    }
    if (user.dmUsername && user.dmUsername !== user.username) {
        message = <Paragraph>All clear, <b>@{user.dmUsername}</b> will notify you by DM!
            Don't forget to follow <Link href='https://twitter.com/unfollowmonkey' source='logged-in-intro'>@unfollowMonkey</Link></Paragraph>;
    }
    return <div className={Styles.loggedInDetails}>
        <Paragraph>{user.dmUsername && <Validate color='neutral-1' className={Styles.centerIcon}/>} Welcome, <b>@{user.username}</b>!</Paragraph>
        {message}
	  {user.dmUsername && <LanguageSelector value={user.lang} onChange={changeLang}/> }
	  {user.dmUsername && <ProCard user={user} setUserInfo={setUserInfo} setHasError={setHasError}/> }
	  {user.dmUsername && <Paragraph><small>Any issue with your pro subscription? <br/> Email us: <i>love@unfollow-monkey.com</i></small></Paragraph> }
		{user.dmUsername && <Paragraph><small>You don't receive your DMs? Read <Link href={'https://twitter.com/UnfollowMonkey/status/1589955137480818688'}>this thread</Link></small></Paragraph> }

        <Paragraph>
            <Link href='#' onClick={e => {logout();e.preventDefault();}} source='disconnect'>Log out</Link>
            {user.dmUsername && <> — <Link href='#' onClick={e => {removeDMs();e.preventDefault();}} source='disable'>Disable the service</Link></>}
        </Paragraph>
    </div>
};

/**
 * Adds html newlines, and bold twitter usernames
 */
function formatMessage(message) {
	const result = [];
	const parts = message.split(/(@\w{1,15})/g);
	for(let i = 0; i < parts.length; i+=2) {
		result.push(parts[i].split('\n').map(line => <>{line}<br/></>));
		result.push(<b>{parts[i+1]}</b>);
	}
	return result;
}

const MessagesAccordion = (latestMessages) => {
	return <Accordion>
		<AccordionPanel label="Last notifications sent">
			<Table>
				<TableHeader>
					<TableRow>
						<TableCell scope="col" border="bottom">
							Date and Time
						</TableCell>
						<TableCell scope="col" border="bottom">
							Message
						</TableCell>
					</TableRow>
				</TableHeader>
				<TableBody>
					{latestMessages ? latestMessages.map((message) =>
						<TableRow>
							<TableCell>
								<i>{new Date(message.sentAt).toLocaleString()}</i>
							</TableCell>
							<TableCell>{formatMessage(message.message)}</TableCell>
						</TableRow>
					) : <TableRow>
						<TableCell className={Styles.spinnerCell}>
							<Spinner />
						</TableCell>
						<TableCell><i>Chargement des messages...</i></TableCell>
					</TableRow>}
					{latestMessages?.length === 0 ?
						<TableRow>
							<TableCell>
							</TableCell>
							<TableCell><i>Aucune notification envoyée. Revenez plus tard!</i></TableCell>
						</TableRow> : null}
				</TableBody>
			</Table>
		</AccordionPanel>
	</Accordion>;
}

function MiniApp(props) {
  const [userInfo, setUserInfo] = useState(null);
  const [latestMessages, setLatestMessages] = useState(null);
  const [hasError, setHasError] = useState(false);

  // persist the userInfo in sessionStorage
  useEffect(() => { userInfo && sessionStorage.setItem('userInfo', JSON.stringify(userInfo)) }, [userInfo]);

  useEffect(() => {
	if (navigator.userAgent !== "ReactSnap") { // We don't want to risk hasError=true on ReactSnap
	  // first: load userInfo from the sessionStorage
	  const storedUserInfo = JSON.parse(sessionStorage.getItem('userInfo'));
	  if (storedUserInfo?.username) { // logged in user
		setUserInfo(storedUserInfo);
	  }

	  // second: load it more accurately from the server
	  fetch(API_URL + '/get-status', {credentials: 'include'})
		.then(response => response.ok ? response.json() : null)
		.then(data => data || Promise.reject())
		.then(data => setUserInfo(data))
		.catch(() => {
		  setHasError(true)
		});
	}
  }, []);

	useEffect(() => {
		if (userInfo?.username) {
			fetch(API_URL + '/user/latest-notifications', {credentials: 'include'})
				.then(response => response.ok ? response.json() : null)
				.then(data => data || Promise.reject())
				.then(data => setLatestMessages(data))
				.catch((error) => {
					setLatestMessages(null);
					console.error(error);
				})
		}
	}, [userInfo?.username]);

  const logout = () => {
	setUserInfo({});
    fetch(API_URL + '/user/logout', {method: 'post',credentials: 'include'})
	  .then(response => response.ok || Promise.reject())
	  .catch(() => {
	    setUserInfo(userInfo);
	    setHasError(true)
	  })
  };
  const removeDMs = () => {
	setUserInfo({
	  ...userInfo,
	  dmUsername: null,
	  category: 3, // disabled
	});
	fetch(API_URL + '/user/disable', {method: 'post',credentials: 'include'})
	  .then(response => response.ok || Promise.reject())
	  .catch(() => {
		setUserInfo(userInfo);
		setHasError(true)
	  });
  };
  const changeLang = (newLang) => {
	setUserInfo({
	  ...userInfo,
	  lang: newLang,
	});
	fetch(API_URL + '/user/lang', {
	  method: 'put',
	  credentials: 'include',
	  headers: { 'Content-Type': 'application/json' },
	  body: JSON.stringify({lang: newLang})
	})
	  .then(response => response.ok || Promise.reject())
	  .catch(() => {
		setUserInfo(userInfo);
		setHasError(true)
	  });
  }
  // Listen and process postmessages from the API
  // (these are sent in the log in callback page)
  useEffect(() => {
    const processMessage = (event) => {
      if (event.origin !== API_URL) {
		return;
	  }
      const content = JSON.parse(decodeURI(event.data.content));
	  setUserInfo(content);
	}

	window.addEventListener('message', processMessage);
    return function cleanup() {
      window.removeEventListener('message', processMessage);
	};
  }, [])

  const step0 = !userInfo?.username; // not logged in
  const step1 = userInfo?.username && !userInfo.dmUsername; // logged in but no DM account
  const step2 = !!userInfo?.dmUsername; // logged in and have a DM account

  return (
      <Box gap='small' margin={{horizontal: 'small', vertical: 'medium'}} {...props}>
        {hasError ?
		  <Paragraph textAlign='center'><Alert/><br/>Unable to reach the server, try again later...</Paragraph> :
		  <>
			<Confetti active={step2} className={Styles.confettis}/>
			<LoggedInIntro {...{user: userInfo, setUserInfo, changeLang, removeDMs, logout, setHasError}}/>
		    {userInfo?.username ? MessagesAccordion(latestMessages) : null}
		  </>
        }
        <Button
            icon={<Twitter color={step0 ? 'white' : null}/>}
            label='Login to your account'
            primary={step0}
            style={step0 ? {color: 'white'} : {}}
            disabled={!step0 || hasError}
            href={`${API_URL}/auth/step-1`}
			target='_blank'
			rel='opener'
        />
        <Button
            icon={<ChatOption color={step1 ? 'white' : null}/>}
            label={step2 ? 'Change the account that sends the DMs' : 'Enable DM notifications'}
            primary={step1}
            style={step1 ? {color: 'white'} : {}}
            disabled={step0 || hasError}
			href={(step0 || hasError) ? null : (step2 ? `${API_URL}/auth/step-2?force_login=true` : `${API_URL}/auth/step-2`)}
			target='_blank'
			rel='opener'
        />
        <Button
            icon={<UserExpert color={step2 ? 'white' : null}/>}
            label={'Follow @UnfollowMonkey'}
            primary={step2}
            style={step2 ? {color: 'white'} : {}}
            href='https://twitter.com/unfollowmonkey'
            target='_blank'
            rel='noopener'
        />
      </Box>
  );
}

export default MiniApp;


================================================
FILE: unfollow-monkey-ui/src/components/MiniApp.module.scss
================================================
.centerIcon {
  vertical-align: sub;
}

.confettis {
  margin: 0 auto;
}

.loggedInDetails {
  text-align: center;
}

================================================
FILE: unfollow-monkey-ui/src/components/Navbar.js
================================================
import React from 'react';
import {Box, Heading, Image} from "grommet/es6";
import * as Images from "../images";
import Styles from './Navbar.module.scss';
import Link from "./Link";

const Navbar = (props) => (
    <header className={Styles.navbar} {...props}>
      <Link href='https://twitter.com/unfollowMonkey' source='navbar'>
        <Box
            direction='row'
            pad={{horizontal: 'medium', vertical: 'small'}}
        >
		  <div className={Styles.logoContainer}>
            <Image className={Styles.logo} title='logo' margin={{horizontal: 'xsmall'}} src={Images.Logo}/>
		  </div>
		  <Image className={Styles.logoHands} title='logo-hands' margin={{horizontal: 'xsmall'}} src={Images.UnfollowMonkeyHands}/>
		  <Heading level={4} color='dark' margin={{vertical: 'small'}} style={{fontWeight: 500}}>UnfollowMonkey</Heading>
        </Box>
      </Link>
    </header>
);
export default Navbar;

================================================
FILE: unfollow-monkey-ui/src/components/Navbar.module.scss
================================================
.navbar {
  a {
    text-decoration: none;
  }

  transition: margin .2s ease;

  .logoContainer {
    overflow: hidden;
    position: absolute;
    .logo {
      transition: margin .6s ease;
      height: 40px;
    }
  }

  .logoHands {
    height: 40px;
    box-shadow: 0 0 0 1px #ffffff; // better hide background logo during shakes
    z-index: 1;
    pointer-events: none;
  }

  &:hover {
    margin-top: -5px;
    margin-bottom: 5px;

    .logo {
      margin-top: 7px;
      margin-bottom: -7px;

      animation: shake 0.4s;
      animation-iteration-count: infinite;
    }
  }
}

@keyframes shake {
  0% { transform: rotate(-2deg)}
  50% { transform: rotate(2deg) }
  100% { transform: rotate(-2deg)}
}

================================================
FILE: unfollow-monkey-ui/src/components/Repo.js
================================================
import React from 'react';
import Styles from './Repo.module.scss';
import Link from "./Link";

function Repo(props) {
  const {title, description, stars, forks} = props;
    return <div className={Styles.card}>
		<div className={Styles.section}>
		  <svg className={Styles.svg} style={{marginRight: 8}} viewBox="0 0 16 16" version="1.1" width="16" height="16"
			   aria-hidden="true">
			<path fillRule="evenodd"
  d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8zM5 12.25v3.25a.25.25 0 00.4.2l1.45-1.087a.25.25 0 01.3 0L8.6 15.7a.25.25 0 00.4-.2v-3.25a.25.25 0 00-.25-.25h-3.5a.25.25 0 00-.25.25z"/>
		  </svg>
		  <span>
          	<Link className={Styles.title} href={"https://github.com/PLhery/" + title}>{title}</Link>
		  </span>
		</div>
		<div className={Styles.description}>{description}</div>
		<div className={Styles.footer}>
		  <div style={{marginRight: 16}}>
			<span className={Styles.languageName}/>
			<span>TypeScript</span>
		  </div>
		  <div  className={Styles.section} style={{marginRight: 16}}>
			<svg className={Styles.svg} aria-label="stars" viewBox="0 0 16 16" version="1.1" width="16" height="16"
				 role="img">
			  <path fillRule="evenodd" d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25zm0 2.445L6.615 5.5a.75.75 0 01-.564.41l-3.097.45 2.24 2.184a.75.75 0 01.216.664l-.528 3.084 2.769-1.456a.75.75 0 01.698 0l2.77 1.456-.53-3.084a.75.75 0 01.216-.664l2.24-2.183-3.096-.45a.75.75 0 01-.564-.41L8 2.694v.001z"/>
			</svg>
			&nbsp; <span>{stars}</span>
		  </div>
		  <div  className={Styles.section}>
			<svg className={Styles.svg} aria-label="fork" viewBox="0 0 16 16" version="1.1" width="16" height="16"
				 role="img">
			  <path fillRule="evenodd" d="M5 3.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 2.122a2.25 2.25 0 10-1.5 0v.878A2.25 2.25 0 005.75 8.5h1.5v2.128a2.251 2.251 0 101.5 0V8.5h1.5a2.25 2.25 0 002.25-2.25v-.878a2.25 2.25 0 10-1.5 0v.878a.75.75 0 01-.75.75h-4.5A.75.75 0 015 6.25v-.878zm3.75 7.378a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm3-8.75a.75.75 0 100-1.5.75.75 0 000 1.5z"/>
			</svg>
			&nbsp; <span>{forks}</span>
		  </div>
		</div>
	  </div>;
}
export default Repo;

================================================
FILE: unfollow-monkey-ui/src/components/Repo.module.scss
================================================
.card {
  font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;
  border: 1px solid #e1e4e8;
  border-radius: 6px;
  background: white;
  padding: 16px;
  font-size: 14px;
  line-height: 1.5;
  color: #24292e;
  max-width: 300px;
}


.section {
  display: flex;
  align-items: center
}

.title {
  text-decoration: none;
  font-weight: 600;
  color: #0366d6!important;
}

.description {
  font-size: 12px;
  margin-bottom: 16px;
  margin-top: 8px;
  color: #586069;
}

.footer {
  font-size: 12px;
  color: #586069;
  display: flex;
}
.languageName {
  width: 12px;
  height: 12px;
  border-radius: 100%;
  background-color: #2b7489;
  display: inline-block; top: 1px;
  position: relative;
}

.svg {
  fill: #586069;
}

================================================
FILE: unfollow-monkey-ui/src/components/Section.js
================================================
import React from 'react';
import {Box} from "grommet/es6";
import Styles from './Section.module.scss';

const Section = (props) => (
    <Box align='center' className={props.sloped ? Styles.sloped : ''} {...props}>
      <Box width='xlarge'>
        {props.children}
      </Box>
    </Box>
);
export default Section;

================================================
FILE: unfollow-monkey-ui/src/components/Section.module.scss
================================================
.sloped {
  padding-top:80px!important;
  clip-path: polygon(
                  0 0,
                  100% 80px,
                  100% 100%,
                  0 100%
  );
}

================================================
FILE: unfollow-monkey-ui/src/components/index.js
================================================
export {default as Faq} from './Faq';
export {default as Link} from './Link';
export {default as MiniApp} from './MiniApp';
export {default as Navbar} from './Navbar';
export {default as Repo} from './Repo';
export {default as Section} from './Section';

================================================
FILE: unfollow-monkey-ui/src/images/flags/index.js
================================================
export {default as fr} from './fr.svg';
export {default as en} from './en.svg';
export {default as es} from './es.svg';
export {default as pt} from './pt.svg';
export {default as id} from './id.svg';
export {default as de} from './de.svg';
export {default as th} from './th.svg';
export {default as pl} from './pl.svg';
export {default as zh_Hans} from './cn.svg';
export {default as nl} from './nl.svg';
export {default as tr} from './tr.svg';
export {default as uk} from './ua.svg';
export {default as pt_BR} from './br.svg';
export {default as ar} from './eg.svg';
export {default as zgh} from './ma.svg';


================================================
FILE: unfollow-monkey-ui/src/images/index.js
================================================
import { useState, useEffect } from 'react';
import Alaska from './alaska.jpg';
import AlaskaWebp from './alaska.webp';

export {default as Dog} from './dog.svg'
export {default as Logo}from './logo.svg';
export {default as DmScreenshot} from './dmscreenshot.png'
export {default as Affinitweet} from './affinitweet.png'
export {default as Uzzy} from './uzzy.svg'
export {default as UnfollowNinja} from './unfollowninja.svg'
export {default as UnfollowMonkeyHands} from './unfollowmonkey-hands.svg'
export {default as applePay} from './apple-pay.svg'
export {default as googlePay} from './google-pay.svg'
export {default as mastercard} from './mastercard.svg'

export function useAlaska() { // react hook to get the right alaska image url
    const [supportsWebP, setWebP] = useState(null); // true if supports, otherwise false
    useEffect(() => {
        const img = new window.Image();
        img.onload = () => setWebP((img.width > 0) && (img.height > 0));
        img.onerror = () => setWebP(false);
        img.src = 'data:image/webp;base64,UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA';
    }, []);
    if (supportsWebP === true) {
        return AlaskaWebp;
    } else if (supportsWebP === false) {
        return Alaska;
    } else {
        return null;
    }
}



================================================
FILE: unfollow-monkey-ui/src/index.js
================================================
import React from 'react';
import { hydrate, render } from "react-dom";
import App from './App';
import * as Sentry from '@sentry/browser';
import * as serviceWorker from './serviceWorkerRegistration';

const DSN = process.env.REACT_APP_SENTRY_DSN;
if (DSN) {
  Sentry.init({dsn: DSN});
}

const rootElement = document.getElementById("root");
if (rootElement.hasChildNodes()) {
  hydrate(<App />, rootElement);
} else {
  render(<App />, rootElement);
}

// ReactDOM.render(<App />, document.getElementById('root'));

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();
/*serviceWorker.register({
  onUpdate: registration => {
    // reload the page if there is an update
    if (registration && registration.waiting) {
      registration.waiting.postMessage({ type: 'SKIP_WAITING' });
    }
    window.location.reload();
  }
});*/


================================================
FILE: unfollow-monkey-ui/src/reportWebVitals.js
================================================
const reportWebVitals = (onPerfEntry) => {
  if (onPerfEntry && onPerfEntry instanceof Function) {
    import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
      getCLS(onPerfEntry);
      getFID(onPerfEntry);
      getFCP(onPerfEntry);
      getLCP(onPerfEntry);
      getTTFB(onPerfEntry);
    });
  }
};

export default reportWebVitals;


================================================
FILE: unfollow-monkey-ui/src/service-worker.js
================================================
/* eslint-disable no-restricted-globals */

// This service worker can be customized!
// See https://developers.google.com/web/tools/workbox/modules
// for the list of available Workbox modules, or add any other
// code you'd like.
// You can also remove this file if you'd prefer not to use a
// service worker, and the Workbox build step will be skipped.

import { clientsClaim } from 'workbox-core';
import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate } from 'workbox-strategies';

clientsClaim();

// Precache all of the assets generated by your build process.
// Their URLs are injected into the manifest variable below.
// This variable must be present somewhere in your service worker file,
// even if you decide not to use precaching. See https://cra.link/PWA
precacheAndRoute(self.__WB_MANIFEST);

// Set up App Shell-style routing, so that all navigation requests
// are fulfilled with your index.html shell. Learn more at
// https://developers.google.com/web/fundamentals/architecture/app-shell
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$');
registerRoute(
  // Return false to exempt requests from being fulfilled by index.html.
  ({ request, url }) => {
    // If this isn't a navigation, skip.
    if (request.mode !== 'navigate') {
      return false;
    } // If this is a URL that starts with /_, skip.

    if (url.pathname.startsWith('/_')) {
      return false;
    } // If this looks like a URL for a resource, because it contains // a file extension, skip.

    if (url.pathname.match(fileExtensionRegexp)) {
      return false;
    } // Return true to signal that we want to use the handler.

    return true;
  },
  createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
);

// An example runtime caching route for requests that aren't handled by the
// precache, in this case same-origin .png requests like those from in public/
registerRoute(
  // Add in any other file extensions or routing criteria as needed.
    // Customize this strategy as needed, e.g., by changing to CacheFirst.
  ({ url }) => url.origin === self.location.origin &&
      (url.pathname.endsWith('.png') || url.pathname.endsWith('.jpg') || url.pathname.endsWith('.svg')),
  new StaleWhileRevalidate({
    cacheName: 'images',
    plugins: [
      // Ensure that once this runtime cache reaches a maximum size the
      // least-recently used images are removed.
      new ExpirationPlugin({ maxEntries: 50 }),
    ],
  })
);

// This allows the web app to trigger skipWaiting via
// registration.waiting.postMessage({type: 'SKIP_WAITING'})
self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

// Any other custom service worker logic can go here.


================================================
FILE: unfollow-monkey-ui/src/serviceWorkerRegistration.js
================================================
// This optional code is used to register a service worker.
// register() is not called by default.

// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.

// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://cra.link/PWA

const isLocalhost = Boolean(
  window.location.hostname === 'localhost' ||
    // [::1] is the IPv6 localhost address.
    window.location.hostname === '[::1]' ||
    // 127.0.0.0/8 are considered localhost for IPv4.
    window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)
);

export function register(config) {
  if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
    // The URL constructor is available in all browsers that support SW.
    const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
    if (publicUrl.origin !== window.location.origin) {
      // Our service worker won't work if PUBLIC_URL is on a different origin
      // from what our page is served on. This might happen if a CDN is used to
      // serve assets; see https://github.com/facebook/create-react-app/issues/2374
      return;
    }

    window.addEventListener('load', () => {
      const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;

      if (isLocalhost) {
        // This is running on localhost. Let's check if a service worker still exists or not.
        checkValidServiceWorker(swUrl, config);

        // Add some additional logging to localhost, pointing developers to the
        // service worker/PWA documentation.
        navigator.serviceWorker.ready.then(() => {
          console.log(
            'This web app is being served cache-first by a service ' +
              'worker. To learn more, visit https://cra.link/PWA'
          );
        });
      } else {
        // Is not localhost. Just register service worker
        registerValidSW(swUrl, config);
      }
    });
  }
}

function registerValidSW(swUrl, config) {
  navigator.serviceWorker
    .register(swUrl)
    .then((registration) => {
      registration.onupdatefound = () => {
        const installingWorker = registration.installing;
        if (installingWorker == null) {
          return;
        }
        installingWorker.onstatechange = () => {
          if (installingWorker.state === 'installed') {
            if (navigator.serviceWorker.controller) {
              // At this point, the updated precached content has been fetched,
              // but the previous service worker will still serve the older
              // content until all client tabs are closed.
              console.log(
                'New content is available and will be used when all ' +
                  'tabs for this page are closed. See https://cra.link/PWA.'
              );

              // Execute callback
              if (config && config.onUpdate) {
                config.onUpdate(registration);
              }
            } else {
              // At this point, everything has been precached.
              // It's the perfect time to display a
              // "Content is cached for offline use." message.
              console.log('Content is cached for offline use.');

              // Execute callback
              if (config && config.onSuccess) {
                config.onSuccess(registration);
              }
            }
          }
        };
      };
    })
    .catch((error) => {
      console.error('Error during service worker registration:', error);
    });
}

function checkValidServiceWorker(swUrl, config) {
  // Check if the service worker can be found. If it can't reload the page.
  fetch(swUrl, {
    headers: { 'Service-Worker': 'script' },
  })
    .then((response) => {
      // Ensure service worker exists, and that we really are getting a JS file.
      const contentType = response.headers.get('content-type');
      if (
        response.status === 404 ||
        (contentType != null && contentType.indexOf('javascript') === -1)
      ) {
        // No service worker found. Probably a different app. Reload the page.
        navigator.serviceWorker.ready.then((registration) => {
          registration.unregister().then(() => {
            window.location.reload();
          });
        });
      } else {
        // Service worker found. Proceed as normal.
        registerValidSW(swUrl, config);
      }
    })
    .catch(() => {
      console.log('No internet connection found. App is running in offline mode.');
    });
}

export function unregister() {
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.ready
      .then((registration) => {
        registration.unregister();
      })
      .catch((error) => {
        console.error(error.message);
      });
  }
}


================================================
FILE: unfollow-monkey-ui/src/style.scss
================================================
body {
  margin: 0;
  color: #4c4e6e;
}
img {
  vertical-align: middle;
}

================================================
FILE: unfollow-monkey-ui/src/twemojis/Emojis.js
================================================
import React from 'react';
import Styles from './Emojis.module.scss';

import ImgWavingHand from './1f44b.png';
import ImgSeeNoEvil from './1f648.png';
import ImgNoEntry from './26d4.png';
import ImgPoo from './1f4a9.png';
import ImgBrokenHeart from './1f494.png';

const EmoImg = ({alt, src}) => <img draggable="false" className={Styles.emoji} alt={alt} src={src}/>;
export const WavingHand = () => <EmoImg alt="👋" src={ImgWavingHand}/>;
export const SeeNoEvil = () => <EmoImg alt="🙈" src={ImgSeeNoEvil}/>;
export const NoEntry = () => <EmoImg alt="⛔" src={ImgNoEntry}/>;
export const Poo = () => <EmoImg alt="💩" src={ImgPoo}/>;
export const BrokenHeart = () => <EmoImg alt="💔" src={ImgBrokenHeart}/>;

const Emojis = { WavingHand, SeeNoEvil, NoEntry, Poo, BrokenHeart };
export default Emojis;

================================================
FILE: unfollow-monkey-ui/src/twemojis/Emojis.module.scss
================================================
img.emoji {
  height: 1em;
  width: 1em;
  margin: 0 .05em 0 .1em;
  vertical-align: -0.1em;
}

================================================
FILE: unfollow-ninja-server/.dockerignore
================================================
node_modules
npm-debug.log


================================================
FILE: unfollow-ninja-server/.eslintrc.json
================================================
{
    "env": {
        "browser": false,
        "commonjs": true,
        "es2021": true,
        "jest/globals": true
    },
    "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:jest/recommended", "prettier"],
    "parser": "@typescript-eslint/parser",
    "parserOptions": {
        "ecmaVersion": "latest",
        "project": "./tsconfig.json"
    },
    "plugins": ["@typescript-eslint", "jest"],
    "rules": {
        "@typescript-eslint/unbound-method": "off",
        "jest/unbound-method": "error",
        "@typescript-eslint/no-floating-promises": "error"
    },
    "ignorePatterns": ["dist", "jest.config.js"]
}


================================================
FILE: unfollow-ninja-server/.prettierignore
================================================
dist
node_modules

================================================
FILE: unfollow-ninja-server/.prettierrc.json
================================================
{
    "singleQuote": true,
    "tabWidth": 4,
    "printWidth": 120
}


================================================
FILE: unfollow-ninja-server/Dockerfile
================================================
# -- For workers only, this does not launch the API
FROM node:18

# Create app directory
WORKDIR /usr/src/app

# Install app dependencies
COPY package*.json ./
RUN npm ci

# Bundle app source
COPY src src
COPY *.json ./
COPY *.js ./
RUN npm run build

COPY tests tests
COPY locales locales
COPY .env .env

CMD [ "node", "./dist/workers.js" ]


================================================
FILE: unfollow-ninja-server/docker-compose.yml
================================================
version: '3'

services:
    workers:
        restart: always
        build: .
        depends_on:
            - postgres
            - postgres-logs
            - postgres-followers
            - redis-bull
            - redis
        volumes:
            - /data/workers-logs:/usr/src/app/logs
        environment:
            POSTGRES_URI: postgres://postgres:unfollowninja@postgres/postgres
            POSTGRES_LOGS_URI: postgres://postgres:unfollowninja@postgres-logs/postgres
            POSTGRES_FOLLOWERS_URI: postgres://postgres:unfollowninja@postgres-followers/postgres
            REDIS_URI: redis://redis
            REDIS_BULL_URI: redis://redis-bull
    api:
        restart: always
        build: .
        command: ['node', './dist/api.js']
        ports:
            - '127.0.0.1:4000:4000'
        depends_on:
            - postgres
            - postgres-logs
            - postgres-followers
            - redis-bull
            - redis
        environment:
            POSTGRES_URI: postgres://postgres:unfollowninja@postgres/postgres
            POSTGRES_LOGS_URI: postgres://postgres:unfollowninja@postgres-logs/postgres
            POSTGRES_FOLLOWERS_URI: postgres://postgres:unfollowninja@postgres-followers/postgres
            REDIS_URI: redis://redis
            REDIS_BULL_URI: redis://redis-bull
    postgres:
        restart: always
        image: postgres:15
        command: postgres -c 'max_connections=200'
        environment:
            POSTGRES_PASSWORD: 'unfollowninja'
        volumes:
            - /data/postgres:/var/lib/postgresql/data
    postgres-logs:
        restart: always
        image: postgres:15
        command: postgres -c 'max_connections=200'
        environment:
            POSTGRES_PASSWORD: 'unfollowninja'
        volumes:
            - /data/postgres-logs:/var/lib/postgresql/data
    postgres-followers:
        restart: always
        image: postgres:15
        command: postgres -c 'max_connections=200'
        environment:
            POSTGRES_PASSWORD: 'unfollowninja'
        volumes:
            - /data/postgres-logs:/var/lib/postgresql/data
    redis-bull:
        restart: always
        image: redis:6
        command: ['redis-server', '--appendonly', 'yes']
        volumes:
            - /data/redis-bull:/data
    redis:
        restart: always
        image: redis:6
        command: ['redis-server', '--appendonly', 'yes']
        volumes:
            - /data/redis:/data


================================================
FILE: unfollow-ninja-server/jest.config.js
================================================
module.exports = {
    clearMocks: true,
    collectCoverage: false,
    collectCoverageFrom: ['src/tasks/*.ts'],
    coverageDirectory: 'test-results/coverage',
    coveragePathIgnorePatterns: ['index.ts'],
    coverageReporters: ['lcov'],
    globals: {
        'ts-jest': {
            tsconfig: 'tsconfig.json',
        },
    },
    moduleFileExtensions: ['js', 'ts', 'tsx'],
    reporters: ['default'],
    testEnvironment: 'node',
    testMatch: ['**/tests/**/*.spec.+(ts|tsx|js)'],
    verbose: true,
    preset: 'ts-jest',
};


================================================
FILE: unfollow-ninja-server/locales/ar.json
================================================
{
    "This account followed you for {{duration}} ({{{time}}}).": "هذا الحساب قام بمتابعتك منذ {{duration}} ({{{time}}}).",
    "and {{nbLeftovers}} more.": "و{{nbLeftovers}} أكثر.",
    "{{nbUnfollows}} Twitter users unfollowed you:": "قام {{nbUnfollows}} مستخدمي تويتر بإلغاء متابعتك:",
    "one of your followers": "واحد من متابعينك",
    "{{username}} unfollowed you {{emoji}}.": "{{username}} ألغى مُتابعتك {{emoji}}.",
    "You blocked {{username}} {{emoji}}.": "قمت بحظر {{username}} {{emoji}}.",
    "This account followed you before you signed up to @{{twitterAccount}}!": "هذا الحساب قام بمتابعتك من قبل تسجيلك في @{{twitterAccount}}!",
    "{{username}}'s account has been locked {{emoji}}.": "حساب {{username}} قد تم إغلاقه {{emoji}}.",
    "Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!": "تهانينا، يُمكنك الآن الاستمتاع بـ @{{twitterAccount}} المميز {{emoji}}!",
    "{{username}} has left Twitter {{emoji}}.": "قام {{username} بمُغادرة تويتر {{emoji}}.",
    "{{username}} has been suspended {{emoji}}.": "{{username}} توقف حسابه {{emoji}}.",
    "All set, welcome to @{{twitterAccount}} {{emoji}}!\nYou will soon know all about your unfollowers here!": "كل شيء جاهز، مرحبًا بك في @{{twitterAccount}} {{emoji}}!\nستعرف قريبًا كل شيء عن غير متابعينك هنا!",
    "{{username}} blocked you {{emoji}}.": "قام {{username}} بحظرك {{emoji}}."
}


================================================
FILE: unfollow-ninja-server/locales/de.json
================================================
{
    "{{username}} has been suspended {{emoji}}.": "{{username}} ist gesperrt worden {{emoji}}.",
    "{{username}} has left Twitter {{emoji}}.": "{{username}} hat Twitter verlassen {{emoji}}.",
    "{{nbUnfollows}} Twitter users unfollowed you:": "{{nbUnfollows}} Twitter-Nutzer haben dir entfolgt:",
    "{{username}} blocked you {{emoji}}.": "{{username}} hat dich blockiert {{emoji}}.",
    "one of your followers": "einer deiner Follower",
    "{{username}} unfollowed you {{emoji}}.": "{{username}} hat dich entfolgt {{emoji}}.",
    "This account followed you for {{duration}} ({{{time}}}).": "Dieses Konto ist dir seit {{duration}} ({{{time}}}) gefolgt.",
    "and {{nbLeftovers}} more.": "und {{nbLeftovers}} mehr.",
    "All set, welcome to @{{twitterAccount}} {{emoji}}!\nYou will soon know all about your unfollowers here!": "Alles bereit, willkommen bei @{{twitterAccount}} {{emoji}}!\nHier wirst du bald alles über deine Unfollower erfahren!",
    "You blocked {{username}} {{emoji}}.": "Du hast {{username}} blockiert {{emoji}}.",
    "This account followed you before you signed up to @{{twitterAccount}}!": "Dieses Konto ist dir gefolgt, bevor du dich bei @{{twitterAccount}} angemeldet hast!",
    "{{username}}'s account has been locked {{emoji}}.": "Das Konto von {{username}} ist gesperrt worden {{emoji}}."
}


================================================
FILE: unfollow-ninja-server/locales/en.json
================================================
{
    "{{nbUnfollows}} Twitter users unfollowed you:": "{{nbUnfollows}} Twitter users unfollowed you:",
    "one of your followers": "one of your followers",
    "{{username}} unfollowed you {{emoji}}.": "{{username}} unfollowed you {{emoji}}.",
    "{{username}} has been suspended {{emoji}}.": "{{username}} has been suspended {{emoji}}.",
    "{{username}} has left Twitter {{emoji}}.": "{{username}} has left Twitter {{emoji}}.",
    "{{username}} blocked you {{emoji}}.": "{{username}} blocked you {{emoji}}.",
    "You blocked {{username}} {{emoji}}.": "You blocked {{username}} {{emoji}}.",
    "This account followed you for {{duration}} ({{{time}}}).": "This account followed you for {{duration}} ({{{time}}}).",
    "This account followed you before you signed up to @{{twitterAccount}}!": "This account followed you before you signed up to @{{twitterAccount}}!",
    "and {{nbLeftovers}} more.": "and {{nbLeftovers}} more.",
    "All set, welcome to @{{twitterAccount}} {{emoji}}!\nYou will soon know all about your unfollowers here!": "All set, welcome to @{{twitterAccount}} {{emoji}}!\nYou will soon know all about your unfollowers here!",
    "{{username}}'s account has been locked {{emoji}}.": "{{username}}'s account has been locked {{emoji}}.",
    "Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!": "Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!"
}


================================================
FILE: unfollow-ninja-server/locales/es.json
================================================
{
    "{{nbUnfollows}} Twitter users unfollowed you:": "{{nbUnfollows}} usuarios de twitter te han dejado de seguir:",
    "one of your followers": "Uno de tus seguidores",
    "{{username}} unfollowed you {{emoji}}.": "{{username}} te dejó de seguir {{emoji}}.",
    "{{username}} has been suspended {{emoji}}.": "{{username}} ha sido suspendido {{emoji}}.",
    "{{username}} has left Twitter {{emoji}}.": "{{username}} dejó Twitter {{emoji}}.",
    "{{username}} blocked you {{emoji}}.": "{{username}} te bloqueó {{emoji}}.",
    "You blocked {{username}} {{emoji}}.": "Has bloqueado a {{username}} {{emoji}}.",
    "This account followed you for {{duration}} ({{{time}}}).": "Esta cuenta te siguió por {{duration}} ({{{time}}}).",
    "This account followed you before you signed up to @{{twitterAccount}}!": "Esta cuenta te seguía antes de que te registrases en @{{twitterAccount}}!",
    "and {{nbLeftovers}} more.": "y {{nbLeftovers}} más.",
    "All set, welcome to @{{twitterAccount}} {{emoji}}!\nYou will soon know all about your unfollowers here!": "Todo listo, bienvenido a @{{twitterAccount}} {{emoji}}!\nPronto conocerás todo sobre tus unfollowers!",
    "{{username}}'s account has been locked {{emoji}}.": "La cuenta de {{username}} ha sido limitada {{emoji}}.",
    "Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!": "Enhorabuena, ahora puedes disfrutar @{{twitterAccount}} pro {{emoji}}!"
}


================================================
FILE: unfollow-ninja-server/locales/fr.json
================================================
{
    "{{nbUnfollows}} Twitter users unfollowed you:": "{{nbUnfollows}} twittos vous ont unfollow :",
    "one of your followers": "Un twitto",
    "{{username}} unfollowed you {{emoji}}.": "{{username}} vous a unfollow {{emoji}}.",
    "{{username}} has been suspended {{emoji}}.": "{{username}} a été suspendu {{emoji}}.",
    "{{username}} has left Twitter {{emoji}}.": "{{username}} a quitté Twitter {{emoji}}.",
    "{{username}} blocked you {{emoji}}.": "{{username}} vous a bloqué {{emoji}}.",
    "You blocked {{username}} {{emoji}}.": "Vous avez bloqué {{username}} {{emoji}}.",
    "This account followed you for {{duration}} ({{{time}}}).": "Ce compte vous a suivi pendant {{duration}} ({{{time}}}).",
    "This account followed you before you signed up to @{{twitterAccount}}!": "Ce compte vous suivait avant votre inscription à @{{twitterAccount}} !",
    "and {{nbLeftovers}} more.": "et {{nbLeftovers}} autres twittos.",
    "All set, welcome to @{{twitterAccount}} {{emoji}}!\nYou will soon know all about your unfollowers here!": "Tout est prêt, bienvenue sur @{{twitterAccount}} {{emoji}} !\nVous allez bientot tout savoir sur vos unfollowers ici !",
    "{{username}}'s account has been locked {{emoji}}.": "Le compte de {{username}} a été verrouillé {{emoji}}.",
    "Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!": "Félicitations, vous pouvez maintenant profiter de @{{twitterAccount}} pro {{emoji}} !"
}


================================================
FILE: unfollow-ninja-server/locales/hy.json
================================================
{}


================================================
FILE: unfollow-ninja-server/locales/id.json
================================================
{
    "This account followed you before you signed up to @{{twitterAccount}}!": "Akun ini sudah mengikuti kamu sebelum kamu mendaftar ke @{{twitterAccount}}!",
    "{{username}} unfollowed you {{emoji}}.": "{{username}} berhenti mengikuti kamu {{emoji}}.",
    "one of your followers": "salah satu pengikut kamu",
    "{{username}} blocked you {{emoji}}.": "{{username}} memblokir kamu {{emoji}}.",
    "You blocked {{username}} {{emoji}}.": "Kamu memblokir {{username}} {{emoji}}.",
    "{{username}} has been suspended {{emoji}}.": "{{username}} sudah ditangguhkan {{emoji}}.",
    "{{username}} has left Twitter {{emoji}}.": "{{username}} sudah meninggalkan Twitter {{emoji}}.",
    "This account followed you for {{duration}} ({{{time}}}).": "Akun ini sudah mengikuti kamu selama {{duration}} ({{{time}}}).",
    "and {{nbLeftovers}} more.": "dan {{nbLeftovers}} lainnya.",
    "{{username}}'s account has been locked {{emoji}}.": "{{username}} akunnya sudah dikunci {{emoji}}.",
    "All set, welcome to @{{twitterAccount}} {{emoji}}!\nYou will soon know all about your unfollowers here!": "Selesai, selamat datang di @{{twitterAccount}} {{emoji}}!\nKamu akan segera tahu tentang semua pengikutmu di sini!",
    "{{nbUnfollows}} Twitter users unfollowed you:": "{{nbUnfollows}} pengguna twitter berhenti mengikuti kamu:",
    "Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!": "Selamat, sekarang kamu bisa menggunakan @{{twitterAccount}} pro {{emoji}}!"
}


================================================
FILE: unfollow-ninja-server/locales/nl.json
================================================
{
    "{{nbUnfollows}} Twitter users unfollowed you:": "{{nbUnfollows}} Twittergebruikers die je ontvolgden:",
    "one of your followers": "een van je volgers",
    "{{username}} unfollowed you {{emoji}}.": "{{username}} heeft je ontvolgt {{emoji}}.",
    "{{username}} has been suspended {{emoji}}.": "{{username}} is opgeschort {{emoji}}.",
    "{{username}} has left Twitter {{emoji}}.": "{{username}} heeft Twitter verlaten {{emoji}}.",
    "{{username}} blocked you {{emoji}}.": "{{username}} heeft je geblokkeerd {{emoji}}.",
    "You blocked {{username}} {{emoji}}.": "Je blokkeerde {{username}} {{emoji}}.",
    "This account followed you for {{duration}} ({{{time}}}).": "Dit account heeft je gevolgd voor {{duration}} ({{{time}}}).",
    "This account followed you before you signed up to @{{twitterAccount}}!": "Dit account volgde je voordat je je aanmeldde bij @{{twitterAccount}}!",
    "Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!": "Gefeliciteerd! Je kan nu van @{{twitterAccount}} pro {{emoji}} genieten!",
    "and {{nbLeftovers}} more.": "en {{nbLeftovers}} meer.",
    "All set, welcome to @{{twitterAccount}} {{emoji}}!\nYou will soon know all about your unfollowers here!": "Alles gedaan, welkom tot @{{twitterAccount}} {{emoji}}!\nOver een beetje kan je zien wie je heeft geontvolgt!",
    "{{username}}'s account has been locked {{emoji}}.": "{{username}}'s account werd vergrendeld {{emoji}}."
}


================================================
FILE: unfollow-ninja-server/locales/pl.json
================================================
{
    "{{username}} has been suspended {{emoji}}.": "{{username}} został zawieszony {{emoji}}.",
    "{{username}} has left Twitter {{emoji}}.": "{{username}} opuścił Twittera {{emoji}}.",
    "{{username}} blocked you {{emoji}}.": "{{username}} cię zablokował {{emoji}}.",
    "and {{nbLeftovers}} more.": "oraz {{nbLeftovers}} innych.",
    "All set, welcome to @{{twitterAccount}} {{emoji}}!\nYou will soon know all about your unfollowers here!": "Wszystko ustawione, witamy w @{{twitterAccount}} {{emoji}}!\nNiedługo, będziesz wiedział wszystko o osobach, które już cię nie obserwują!",
    "{{username}} unfollowed you {{emoji}}.": "{{username}} przestał cię obserwować {{emoji}}.",
    "one of your followers": "jeden z twoich obserwujących",
    "{{nbUnfollows}} Twitter users unfollowed you:": "{{nbUnfollows}} użytkowników przestało cię obserwować:",
    "You blocked {{username}} {{emoji}}.": "Zablokowałeś {{username}} {{emoji}}.",
    "This account followed you for {{duration}} ({{{time}}}).": "Te konto obserwowało cię przez {{duration}} ({{{time}}}).",
    "This account followed you before you signed up to @{{twitterAccount}}!": "Te konto zaobserwowało cię przed tym jak się zarejestrowałeś na @{{twitterAccount}}!",
    "{{username}}'s account has been locked {{emoji}}.": "Konto {{username}} zostało zablokowane {{emoji}}.",
    "Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!": "Gratulacje, możesz teraz korzystać z @{{twitterAccount} pro {{emoji}}!"
}


================================================
FILE: unfollow-ninja-server/locales/pt.json
================================================
{
    "{{nbUnfollows}} Twitter users unfollowed you:": "{{nbUnfollows}} utilizadores do twitter deixaram de te seguir:",
    "one of your followers": "um dos teus seguidores",
    "{{username}} unfollowed you {{emoji}}.": "{{username}} deixou de te seguir {{emoji}}.",
    "{{username}} has been suspended {{emoji}}.": "{{username}} foi suspenso {{emoji}}.",
    "{{username}} has left Twitter {{emoji}}.": "{{username}} deixou o Twitter {{emoji}}.",
    "{{username}} blocked you {{emoji}}.": "{{username}} bloqueou-te {{emoji}}.",
    "You blocked {{username}} {{emoji}}.": "Tu bloqueaste o {{username}} {{emoji}}.",
    "This account followed you for {{duration}} ({{{time}}}).": "Esta conta seguia-te por {{duration}} ({{{time}}}).",
    "This account followed you before you signed up to @{{twitterAccount}}!": "Esta conta seguiu-te antes de te inscreveres no @{{twitterAccount}}!",
    "and {{nbLeftovers}} more.": "e {{nbLeftovers}} mais.",
    "All set, welcome to @{{twitterAccount}} {{emoji}}!\nYou will soon know all about your unfollowers here!": "Tudo pronto, bem vindo ao @{{twitterAccount}} {{emoji}}!\nBrevemente vais saber tudo sobre aqueles que te deixam de seguir aqui!",
    "{{username}}'s account has been locked {{emoji}}.": "A conta do {{username}} foi temporariamente trancada {{emoji}}.",
    "Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!": "Parabéns, agora pode desfrutar do @{{twitterAccount}} pro {{emoji}}!"
}


================================================
FILE: unfollow-ninja-server/locales/pt_BR.json
================================================
{
    "{{nbUnfollows}} Twitter users unfollowed you:": "{{nbUnfollows}} Usuários do Twitter deixaram de te seguir:",
    "{{username}} unfollowed you {{emoji}}.": "{{username}} Deixou de seguir você {{emoji}}.",
    "{{username}} has been suspended {{emoji}}.": "{{username}} foi suspenso {{emoji}}.",
    "{{username}} has left Twitter {{emoji}}.": "{{username}} deixou o Twitter {{emoji}}.",
    "{{username}} blocked you {{emoji}}.": "{{username}} te bloqueou {{emoji}}.",
    "This account followed you for {{duration}} ({{{time}}}).": "Essa conta te seguiu por {{duration}} ({{{time}}}).",
    "This account followed you before you signed up to @{{twitterAccount}}!": "Essa conta te seguiu antes de você se inscrever em @{{twitterAccount}}!",
    "and {{nbLeftovers}} more.": "e {{nbLeftovers}} mais.",
    "{{username}}'s account has been locked {{emoji}}.": "A conta {{username}} foi temporariamente trancada {{emoji}}.",
    "Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!": "Parabéns, agora você pode aproveitar @{{twitterAccount}} pro {{emoji}}!",
    "one of your followers": "Um dos seus seguidores",
    "You blocked {{username}} {{emoji}}.": "Você bloqueou {{username}} {{emoji}}.",
    "All set, welcome to @{{twitterAccount}} {{emoji}}!\nYou will soon know all about your unfollowers here!": "Tudo pronto, bem vindo ao @{{twitterAccount}} {{emoji}}!\nEm breve você saberá quem deixou de te seguir aqui!"
}


================================================
FILE: unfollow-ninja-server/locales/ru.json
================================================
{
    "one of your followers": "один из ваших подписчиков",
    "{{username}} blocked you {{emoji}}.": "{{username}} заблокировал вас {{emoji}}.",
    "and {{nbLeftovers}} more.": "и {{nbLeftovers}} многое другое.",
    "You blocked {{username}} {{emoji}}.": "Вы заблокировали {{username}} {{emoji}}.",
    "This account followed you for {{duration}} ({{{time}}}).": "Этот аккаунт читал вас с {{duration}} ({{{time}}}).",
    "{{username}} unfollowed you {{emoji}}.": "{{username}} отписался от вас {{emoji}}.",
    "This account followed you before you signed up to @{{twitterAccount}}!": "Этот аккаунт начал читать вас до того, как вы подключили @{{twitterAccount}}!",
    "{{username}}'s account has been locked {{emoji}}.": "Учетная запись {{username}} была заблокирована {{emoji}}.",
    "{{username}} has left Twitter {{emoji}}.": "{{username}} удалил аккаунт {{emoji}}.",
    "Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!": "Поздравляем, теперь вы можете наслаждаться @{{twitterAccount}} pro {{emoji}}!",
    "{{nbUnfollows}} Twitter users unfollowed you:": "{{nbUnfollows}} Пользователи твиттера, отписавшиеся от вас:",
    "{{username}} has been suspended {{emoji}}.": "{{username}} был приостановлен {{emoji}}.",
    "All set, welcome to @{{twitterAccount}} {{emoji}}!\nYou will soon know all about your unfollowers here!": "Всё готово, добро пожаловать в @{{twitterAccount}} {{emoji}}! \nСкоро вы узнаете о тех, кто отписался!"
}


================================================
FILE: unfollow-ninja-server/locales/sk.json
================================================
{
    "{{nbUnfollows}} Twitter users unfollowed you:": "{{nbUnfollows}} Twitter užívatelia/užívateľov ťa prestalo sledovať",
    "one of your followers": "jeden z tvojich sledovateľov",
    "{{username}} unfollowed you {{emoji}}.": "{{username}} ťa prestal sledovať {{emoji}}.",
    "{{username}} has been suspended {{emoji}}.": "Účet {{username}} bol pozastavený {{emoji}}.",
    "{{username}} has left Twitter {{emoji}}.": "{{username}} opustil Twitter {{emoji}}.",
    "{{username}} blocked you {{emoji}}.": "{{username}} si ťa zablokoval {{emoji}}.",
    "You blocked {{username}} {{emoji}}.": "Zablokoval si {{username}} {{emoji}}.",
    "This account followed you for {{duration}} ({{{time}}}).": "Tento účet ťa sledoval {{duration}} ({{{time}}})."
}


================================================
FILE: unfollow-ninja-server/locales/th.json
================================================
{
    "{{username}} has been suspended {{emoji}}.": "{{username}} ได้ถูกระงับการใช้งาน {{emoji}}.",
    "{{username}} blocked you {{emoji}}.": "{{username}} ทำการบล็อคคุณ {{emoji}}.",
    "You blocked {{username}} {{emoji}}.": "คุณได้บล็อค {{username}} {{emoji}}.",
    "This account followed you before you signed up to @{{twitterAccount}}!": "บัญชีผู้ใช้นี้ติดตามคุณ ก่อนที่คุณจะลงชื่อสมัครใช้ @{{twitterAccount}}!",
    "{{username}}'s account has been locked {{emoji}}.": "{{username}}'s บัญชีผู้ใช้นี้ได้ทำการล็อคบัญชี {{emoji}}.",
    "one of your followers": "หนึ่งในผู้ติดตามของคุณ",
    "{{nbUnfollows}} Twitter users unfollowed you:": "{{nbUnfollows}} บัญชีผู้ใช้ทวิตเตอร์นี้ได้เลิกติดตามคุณ:",
    "{{username}} unfollowed you {{emoji}}.": "{{username}} เลิกติดตามคุณ {{emoji}}.",
    "{{username}} has left Twitter {{emoji}}.": "{{username}} ได้ปิดบัญชีทวิตเตอร์ {{emoji}}.",
    "This account followed you for {{duration}} ({{{time}}}).": "บัญชีผู้ใช้นี้ติดตามคุณเมื่อ {{duration}} ({{{time}}}).",
    "and {{nbLeftovers}} more.": "และ {{nbLeftovers}} อื่นๆ.",
    "All set, welcome to @{{twitterAccount}} {{emoji}}!\nYou will soon know all about your unfollowers here!": "ขั้นตอนเสร็จสิ้น, ยินดีต้อนรับสู่ @{{twitterAccount}} {{emoji}}!\nในไม่ช้าคุณจะรู้ทุกอย่างเกี่ยวกับผู้ที่ติดตามของคุณอยู่!"
}


================================================
FILE: unfollow-ninja-server/locales/tr.json
================================================
{
    "one of your followers": "takipçilerinden biri",
    "{{username}} unfollowed you {{emoji}}.": "{{username}} seni takip etmeyi bıraktı {{emoji}}.",
    "{{username}} has been suspended {{emoji}}.": "{{username}} isimli hesap askıya alındı {{emoji}}.",
    "{{username}} has left Twitter {{emoji}}.": "{{username}} Twitter'dan ayrıldı {{emoji}}.",
    "{{username}} blocked you {{emoji}}.": "{{username}} seni engelledi {{emoji}}.",
    "You blocked {{username}} {{emoji}}.": "{{username}} kişisini engelledin {{emoji}}.",
    "This account followed you for {{duration}} ({{{time}}}).": "Bu hesap seni {{duration}} boyunca takip etmişti ({{{time}}}).",
    "This account followed you before you signed up to @{{twitterAccount}}!": "Bu hesap sen @{{twitterAccount}} hesabına giriş yapmadan takip etti!",
    "and {{nbLeftovers}} more.": "ve {{nbLeftovers}} fazlası.",
    "All set, welcome to @{{twitterAccount}} {{emoji}}!\nYou will soon know all about your unfollowers here!": "Tamamdır! Hoş geldin @{{twitterAccount}} {{emoji}}!\nYakında burada seni takipten çıkaran herkesi öğreneceksin!",
    "{{username}}'s account has been locked {{emoji}}.": "{{username}} kişisinin hesabı kilitlendi {{emoji}}.",
    "Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!": "Tebrikler! Şimdi @{{twitterAccount}} pro ayrıcalıklarından faydalanabilirsin {{emoji}}!",
    "{{nbUnfollows}} Twitter users unfollowed you:": "{{nbUnfollows}} Twitter kullanıcısı seni takip etmeyi bıraktı:"
}


================================================
FILE: unfollow-ninja-server/locales/uk.json
================================================
{
    "one of your followers": "один з ваших підписників",
    "{{username}} unfollowed you {{emoji}}.": "{{username}} відписався від вас {{emoji}}.",
    "You blocked {{username}} {{emoji}}.": "Ви заблокували {{username}} {{emoji}}.",
    "This account followed you for {{duration}} ({{{time}}}).": "Цей акаунт читав вас впродовж {{duration}} ({{{time}}}).",
    "and {{nbLeftovers}} more.": "та {{nbLeftovers}} ще.",
    "{{username}} blocked you {{emoji}}.": "{{username}} заблокував вас {{emoji}}.",
    "All set, welcome to @{{twitterAccount}} {{emoji}}!\nYou will soon know all about your unfollowers here!": "Все готово, вітаємо на @{{twitterAccount}} {{emoji}}!\nСкоро ви дізнаєтесь про всіх ваших відписників тут!",
    "{{username}}'s account has been locked {{emoji}}.": "{{username}} було заблоковано {{emoji}}.",
    "Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!": "Вітаю, тепер можете насолодитись @{{twitterAccount}} pro {{emoji}}!",
    "{{nbUnfollows}} Twitter users unfollowed you:": "{{nbUnfollows}} користувачів Твіттеру відписались від вас:",
    "This account followed you before you signed up to @{{twitterAccount}}!": "Цей акаунт читав вас до того як ви підписались на @{{twitterAccount}}!",
    "{{username}} has been suspended {{emoji}}.": "{{username}} призупинив дію свого акаунту {{emoji}}.",
    "{{username}} has left Twitter {{emoji}}.": "{{username}} покинув Твіттер {{emoji}}."
}


================================================
FILE: unfollow-ninja-server/locales/zgh.json
================================================
{
    "{{nbUnfollows}} Twitter users unfollowed you:": "ⴽⴽⵙⵏ {{nbUnfollows}} ⵉⵏⵙⵙⵎⵔⴰⵙ ⵏ ⵜⵡⵉⵜⵔ ⵜⵉⴹⴼⵕⵉ ⵏⵏⴽ:",
    "one of your followers": "ⵉⵊⵊ ⵙⴳ ⵉⵎⴹⴼⴰⵕⵏ ⵏⵏⴽ",
    "{{username}} unfollowed you {{emoji}}.": "{{username}} ⵉⵙⵙⵔ ⵜⵉⴹⴼⵕⵉ ⵏⵏⴽ {{emoji}}.",
    "{{username}} has been suspended {{emoji}}.": "{{username}} ⵉⴱⴷⴷ ⵓⵎⵉⴹⴰⵏ ⵏⵏⵙ {{emoji}}.",
    "{{username}} has left Twitter {{emoji}}.": "ⵉⴼⴼⵖ {{username} ⵙⴳ ⵜⵡⵉⵜⵔ {{emoji}}.",
    "You blocked {{username}} {{emoji}}.": "ⵜⴽⴽⵙⴷ {{username}} {{emoji}}.",
    "This account followed you for {{duration}} ({{{time}}}).": "ⵉⴹⴼⴰⵕ ⴽ ⵓⵎⵉⴹⴰⵏ ⴰⴷ ⵙⴳ {{duration}} ({{{time}}}).",
    "This account followed you before you signed up to @{{twitterAccount}}!": "ⵉⴹⴼⴰⵕ ⴽ ⵓⵎⵉⴹⴰⵏ ⴰⴷ ⴷⴰⵜ ⵏ ⵓⵣⵎⵎⴻⵎ ⵏⵏⴽ ⴳ @{{twitterAccount}}!",
    "{{username}}'s account has been locked {{emoji}}.": "ⴰⵎⵉⴹⴰⵏ ⵏ {{username}} ⵉⵜⵜⵡⴰⵇⵇⵏ {{emoji}}.",
    "Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!": "ⴰⵢⵢⵓⵣ ⵏⵏⴽ, ⵜⵣⵎⵔⴷ ⴰⴷ ⵜⵖⴱⴱⵉⴷ ⵙ @{{twitterAccount}} ⵉⵖⵓⴷⴰⵏ {{emoji}}!",
    "{{username}} blocked you {{emoji}}.": "{{username}} ⵉⴽⴽⵙ ⴽ {{emoji}}.",
    "and {{nbLeftovers}} more.": "ⴷ {{nbLeftovers}} ⵢⴰⴹⵏ.",
    "All set, welcome to @{{twitterAccount}} {{emoji}}!\nYou will soon know all about your unfollowers here!": "ⵎⴰⵕⵕⴰ ⵉⵙⴽⴽⵉⵏⵏ ⵓⵊⴷⵏ, ⴱⵔⵔⴽⴰⵜ ⴳ @{{twitterAccount}} {{emoji}}!\nⵔⴰⴷ ⵜⵉⵙⵉⵏⴷ ⵎⴰⵕⵕⴰ ⵉⵙⴽⴽⵉⵏⵏ ⵅⴼ ⵉⵔⴰⵎⴹⴼⴰⵕⵏ ⵏⵏⴽ ⴷⴰ!"
}


================================================
FILE: unfollow-ninja-server/locales/zh_Hans.json
================================================
{
    "one of your followers": "你的其中的一个关注者",
    "{{username}} unfollowed you {{emoji}}.": "{{username}} 取消关注你 {{emoji}}.",
    "{{username}} blocked you {{emoji}}.": "{{username}} 屏蔽了你 {{emoji}}.",
    "You blocked {{username}} {{emoji}}.": "你屏蔽了 {{username}} {{emoji}}.",
    "{{username}} has been suspended {{emoji}}.": "{{username}} 被冻结 {{emoji}}.",
    "This account followed you before you signed up to @{{twitterAccount}}!": "这个用户在你注册@{{twitterAccount}} 之前就关注了你!",
    "This account followed you for {{duration}} ({{{time}}}).": "这个用户已经关注你{{duration}} ({{{time}}})了.",
    "Congratulations, can now enjoy @{{twitterAccount}} pro {{emoji}}!": "祝贺,你现在可以好好地使用了! @{{twitterAccount}} {{emoji}}!",
    "{{username}} has left Twitter {{emoji}}.": "{{username}} 注销账号 {{emoji}}.",
    "All set, welcome to @{{twitterAccount}} {{emoji}}!\nYou will soon know all about your unfollowers here!": "好了,欢迎@{{twitterAccount}} {{emoji}}!\n在这里,你可以知道谁取关了你!",
    "{{nbUnfollows}} Twitter users unfollowed you:": "{{nbUnfollows}} 个用户取消关注你:",
    "{{username}}'s account has been locked {{emoji}}.": "{{username}} 账号受限 {{emoji}}.",
    "and {{nbLeftovers}} more.": "和{{nbLeftovers}}等。"
}


================================================
FILE: unfollow-ninja-server/package.json
================================================
{
    "name": "unfollowninja",
    "version": "2.0.0",
    "description": "Receive a direct message in a few seconds when someone unfollows you on Twitter",
    "main": "./dist/workers.js",
    "scripts": {
        "start": "node ./dist/workers.js",
        "start-api": "node ./dist/api.js",
        "test": "npm run lint && npm run specs",
        "specs": "jest",
        "jest": "jest",
        "lint": "prettier --check . && eslint .",
        "watch": "tsc -p tsconfig-build.json --watch",
        "build": "tsc -p tsconfig-build.json"
    },
    "repository": {
        "type": "git",
        "url": "git+https://github.com/plhery/unfollowNinja.git"
    },
    "keywords": [
        "twitter",
        "unfollow",
        "ninja",
        "unfollowninja"
    ],
    "author": "plhery (https://twitter.com/plhery)",
    "license": "ISC",
    "bugs": {
        "url": "https://github.com/plhery/unfollowNinja/issues"
    },
    "homepage": "https://github.com/plhery/unfollowNinja#readme",
    "devDependencies": {
        "@types/jest": "^29.2.2",
        "@typescript-eslint/eslint-plugin": "^5.42.1",
        "@typescript-eslint/parser": "^5.42.1",
        "eslint": "^8.27.0",
        "eslint-config-prettier": "^8.5.0",
        "eslint-plugin-jest": "^27.1.5",
        "ioredis-mock": "^8.2.2",
        "jest": "^29.3.1",
        "prettier": "^2.7.1",
        "sqlite3": "npm:@vscode/sqlite3@^5.0.8",
        "ts-jest": "^29.0.3",
        "typescript": "^4.8.4"
    },
    "dependencies": {
        "@koa/cors": "^4.0.0",
        "@sentry/node": "^7.19.0",
        "@sentry/tracing": "^7.19.0",
        "@types/geoip-country": "^4.0.0",
        "@types/i18n": "^0.13.5",
        "@types/koa-router": "^7.4.4",
        "@types/koa-session": "^5.10.4",
        "@types/node": "^18.11.9",
        "@types/oauth": "^0.9.1",
        "@types/twit": "^2.2.31",
        "big-integer": "^1.6.48",
        "bull": "^4.1.0",
        "dotenv": "^16.0.3",
        "geoip-country": "^4.1.30",
        "hot-shots": "^9.3.0",
        "i18n": "^0.15.1",
        "ioredis": "^5.2.4",
        "iso-country-currency": "^0.6.0",
        "koa": "^2.13.1",
        "koa-bodyparser": "^4.3.0",
        "koa-router": "^12.0.0",
        "koa-session": "^6.2.0",
        "moment": "^2.29.4",
        "moment-timezone": "^0.5.38",
        "p-limit": "^3.1.0",
        "pg": "^8.8.0",
        "pg-hstore": "^2.3.4",
        "sequelize": "^6.25.5",
        "stripe": "^10.17.0",
        "twit": "^2.2.11",
        "twitter-api-v2": "^1.12.9",
        "winston": "^3.8.2"
    },
    "//": [
        "p-limit stuck at v3 because of esm module incompatibility"
    ]
}


================================================
FILE: unfollow-ninja-server/pm2.yml
================================================
apps:
    - script: ./dist/workers.js
      name: ninja-workers
      kill_timeout: 10000
    - script: ./dist/api.js
      name: ninja-api
      exec_mode: cluster
      instances: 2


================================================
FILE: unfollow-ninja-server/src/api/admin.ts
================================================
import Router from 'koa-router';
import type { Queue } from 'bull';

import type Dao from '../dao/dao';
import type { NinjaSession } from '../api';
import { UserCategory } from '../dao/dao';
import { WebEvent } from '../dao/userEventDao';
import { disablePro, enablePro } from './stripe';

export function createAdminRouter(dao: Dao, queue: Queue) {
    return new Router()
        .use(async (ctx, next) => {
            const session = ctx.session as NinjaSession;
            if (!process.env.ADMIN_USERID || session.userId !== process.env.ADMIN_USERID) {
                await ctx.throw(401);
                return;
            }
            await next();
        })
        .get('/user/:usernameOrId', async (ctx) => {
            const userId = await getUserId(ctx.params.usernameOrId);
            const userDao = dao.getUserDao(userId);

            const [
                params,
                username,
                category,
                friendCodes,
                registeredFriendCode,
                notificationEvents,
                categoryEvents,
                webEvents,
                unfollowerEvents,
                followEvents,
                followers,
                uncachables,
            ] = await Promise.all([
                userDao.getUserParams(),
                userDao.getUsername(),
                userDao.getCategory(),
                userDao.getFriendCodes(),
                userDao.getRegisteredFriendCode(),
                dao.userEventDao.getNotificationEvents(userId),
                dao.userEventDao.getCategoryEvents(userId),
                dao.userEventDao.getWebEvents(userId),
                dao.userEventDao.getUnfollowerEvents(userId),
                dao.userEventDao.getFollowEvent(userId),
                userDao.getFollowers(),
                userDao.getUncachableFollowers(),
            ]);

            await dao.userEventDao.logWebEvent(ctx.session.userId, WebEvent.adminFetchUser, ctx.ip, username, userId);

            ctx.body = JSON.stringify(
                {
                    id: userId,
                    username,
                    category,
                    categoryStr: UserCategory[category],
                    addedAt: params.added_at,
                    lang: params.lang,
                    dmId: params.dmId,
                    dmUsername: await dao.getCachedUsername(params.dmId),
                    pro: params.pro,
                    customerId: params.customerId,
                    friendCodes,
                    registeredFriendCode,
                    notificationEvents,
                    categoryEvents,
                    webEvents,
                    unfollowerEvents,
                    followEvents,
                    followers,
                    uncachables,
                },
                null,
                2
            );
            ctx.response.type = 'json';
        })
        .get('/set-pro/:usernameOrId', async (ctx) => {
            const session = ctx.session as NinjaSession;
            const userId = await getUserId(ctx.params.usernameOrId);
            const username = await dao.getCachedUsername(userId);

            await dao.userEventDao.logWebEvent(session.userId, WebEvent.enablePro, ctx.ip, username, userId);
            await enablePro(dao, queue, userId, 'pro', ctx.ip, 'admin-' + session.userId);
            ctx.status = 204;
        })
        .get('/set-friends/:usernameOrId', async (ctx) => {
            const session = ctx.session as NinjaSession;
            const userId = await getUserId(ctx.params.usernameOrId);
            const username = await dao.getCachedUsername(userId);

            await dao.userEventDao.logWebEvent(session.userId, WebEvent.enableFriends, ctx.ip, username, userId);
            await enablePro(dao, queue, userId, 'friends', ctx.ip, 'admin-' + session.userId);
            ctx.status = 204;
        })
        .get('/remove-pro/:usernameOrId', async (ctx) => {
            const session = ctx.session as NinjaSession;
            const userId = await getUserId(ctx.params.usernameOrId);
            const username = await dao.getCachedUsername(userId);

            await dao.userEventDao.logWebEvent(session.userId, WebEvent.disablePro, ctx.ip, username, userId);
            await disablePro(dao, userId, ctx.ip, 'admin-' + session.userId);

            ctx.status = 204;
        })
        .get('/update-params/:usernameOrId', async (ctx) => {
            const userId = await getUserId(ctx.params.usernameOrId);
            await dao.getUserDao(userId).setUserParams(ctx.request.query);
            ctx.status = 204;
        });

    async function getUserId(usernameOrId: string) {
        if (!Number.isNaN(Number(usernameOrId))) {
            // usernameOrId is an ID
            return usernameOrId;
        } else {
            // usernameOrId is a username, look for the ID
            const client = await dao.getUserDao(process.env.ADMIN_USERID).getTwitterApi();
            const result = await client.v1.user({ screen_name: usernameOrId });
            return result.id_str;
        }
    }
}


================================================
FILE: unfollow-ninja-server/src/api/auth.ts
================================================
import TwitterApi from 'twitter-api-v2';
import Router from 'koa-router';
import type { Queue } from 'bull';
import geoip from 'geoip-country';

import logger from '../utils/logger';
import type Dao from '../dao/dao';
import { UserCategory } from '../dao/dao';
import type { NinjaSession } from '../api';
import { Lang } from '../utils/types';
import { WebEvent } from '../dao/userEventDao';
import { getPriceTags } from './stripe';

const authRouter = new Router();

if (
    !process.env.CONSUMER_KEY ||
    !process.env.CONSUMER_SECRET ||
    !process.env.DM_CONSUMER_KEY ||
    !process.env.DM_CONSUMER_SECRET
) {
    logger.error('Some required environment variables are missing ((DM_)CONSUMER_KEY/CONSUMER_SECRET).');
    logger.error('Make sure you added them in a .env file in you cwd or that you defined them.');
    process.exit();
}

const _STEP1_CREDENTIALS = {
    appKey: process.env.CONSUMER_KEY,
    appSecret: process.env.CONSUMER_SECRET,
} as const;
const _STEP2_CREDENTIALS = {
    appKey: process.env.DM_CONSUMER_KEY,
    appSecret: process.env.DM_CONSUMER_SECRET,
} as const;

if (!process.env.API_URL || !process.env.WEB_URL) {
    logger.error('Some required environment variables are missing (API_URL/WEB_URL).');
    logger.error('Make sure you added them in a .env file in you cwd or that you defined them.');
    process.exit();
}

const DEFAULT_LANGUAGE = (process.env.DEFAULT_LANGUAGE || 'en') as Lang;

export function createAuthRouter(dao: Dao, queue: Queue) {
    return authRouter
        .get('/step-1', async (ctx) => {
            // Generate an authentication URL
            const { url, oauth_token, oauth_token_secret } = await new TwitterApi(_STEP1_CREDENTIALS).generateAuthLink(
                process.env.API_URL + '/auth/step-1-callback'
            );

            const session = ctx.session as NinjaSession;
            // store the relevant information in the session
            session.twitterTokenSecret = ctx.session.twitterTokenSecret || {};
            session.twitterTokenSecret[oauth_token] = oauth_token_secret;

            // redirect to the authentication URL
            ctx.redirect(url);
        })
        .get('/step-1-callback', async (ctx) => {
            // check query params and session data
            const { oauth_token, oauth_verifier } = ctx.query;
            if (typeof oauth_token !== 'string' || typeof oauth_verifier !== 'string') {
                ctx.body = { status: 'Oops, it looks like you refused to log in..' };
                ctx.status = 401;
                return;
            }
            const oauthTokenSecret = ctx.session.twitterTokenSecret?.[oauth_token];
            if (typeof oauthTokenSecret !== 'string') {
                ctx.body = {
                    status: 'Oops, it looks like your session has expired.. Try again!',
                };
                ctx.status = 401;
                return;
            }

            // fetch the token / secret / account infos (from the temporary one)
            const loginResult = await new TwitterApi({
                ..._STEP1_CREDENTIALS,
                accessToken: oauth_token,
                accessSecret: oauthTokenSecret,
            }).login(oauth_verifier);

            // fetch user info (and refresh the username cache)
            let [params, category] = await Promise.all([
                dao.getUserDao(loginResult.userId).getUserParams(),
                dao.getUserDao(loginResult.userId).getCategory(),
                dao.addTwittoToCache({
                    id: loginResult.userId,
                    username: loginResult.screenName,
                }),
            ]);

            if (!params.token) {
                // params = {} => the user doesn't exists, let's create it
                params = {
                    added_at: Date.now(),
                    lang: DEFAULT_LANGUAGE,
                    token: loginResult.accessToken,
                    tokenSecret: loginResult.accessSecret,
                };
                category = UserCategory.disabled;
                await dao.addUser({
                    category,
                    id: loginResult.userId,
                    username: loginResult.screenName,
                    ...params,
                });

                void dao.userEventDao.logWebEvent(
                    loginResult.userId,
                    WebEvent.createAccount,
                    ctx.ip,
                    loginResult.screenName
                );
            } else {
                // not a new user
                if (params.tokenSecret !== loginResult.accessSecret) {
                    // after a revoked token => refresh the token
                    await dao.getUserDao(loginResult.userId).setUserParams({
                        token: loginResult.accessToken,
                        tokenSecret: loginResult.accessSecret,
                    });
                }
            }
            const session = ctx.session as NinjaSession;
            session.userId = loginResult.userId;
            session.username = loginResult.screenName;

            void dao.userEventDao.logWebEvent(loginResult.userId, WebEvent.signIn, ctx.ip, loginResult.screenName);
            const country = geoip.lookup(ctx.ip)?.country;
            const msgContent = encodeURI(
                JSON.stringify({
                    userId: loginResult.userId,
                    username: loginResult.screenName,
                    dmUsername:
                        params.dmId && [UserCategory.enabled, UserCategory.vip].includes(category)
                            ? await dao.getCachedUsername(params.dmId)
                            : null,
                    category,
                    lang: params.lang,
                    country,
                    priceTags: getPriceTags(country),
                    isPro: Number(params.pro) > 0,
                    friendCodes:
                        params.pro === '2' ? await dao.getUserDao(session.userId).getFriendCodesWithUsername() : null,
                    hasSubscription: Boolean(params.customerId),
                })
            );

            ctx.type = 'html';
            ctx.body = `You successfully logged in! closing this window...
      <script>
        window.opener && window.opener.postMessage({msg: 'step1', content: "${msgContent}"}, '${process.env.WEB_URL}');
        close();
      </script>`;
        })
        .get('/step-2', async (ctx) => {
            // Generate an authentication URL
            const forceLogin = Boolean(ctx.query.force_login);
            const { url, oauth_token, oauth_token_secret } = await new TwitterApi(_STEP2_CREDENTIALS).generateAuthLink(
                process.env.API_URL + '/auth/step-2-callback',
                {
                    forceLogin,
                }
            );

            // store the relevant information in the session
            const session = ctx.session as NinjaSession;
            session.twitterTokenSecret = ctx.session.twitterTokenSecret || {};
            session.twitterTokenSecret[oauth_token] = oauth_token_secret;

            // redirect to the authentication URL
            ctx.redirect(url);
        })
        .get('/step-2-callback', async (ctx) => {
            // check query params and session data
            const { oauth_token, oauth_verifier } = ctx.query;
            if (typeof oauth_token !== 'string' || typeof oauth_verifier !== 'string') {
                ctx.body = { status: 'Oops, it looks like you refused to log in..' };
                ctx.status = 401;
                return;
            }

            const session = ctx.session as NinjaSession;
            const oauthTokenSecret = session.twitterTokenSecret?.[oauth_token];
            const userId = session.userId;
            if (typeof oauthTokenSecret !== 'string' || typeof userId !== 'string') {
                ctx.body = {
                    status: 'Oops, it looks like your session has expired.. Try again!',
                };
                ctx.status = 401;
                return;
            }

            // fetch the token / secret / account infos (from the temporary one)
            const loginResult = await new TwitterApi({
                ..._STEP2_CREDENTIALS,
                accessToken: oauth_token,
                accessSecret: oauthTokenSecret,
            }).login(oauth_verifier);

            // Add info about the DM account to the user's params
            await Promise.all([
                dao.getUserDao(userId).setUserParams({
                    dmId: loginResult.userId,
                    dmToken: loginResult.accessToken,
                    dmTokenSecret: loginResult.accessSecret,
                }),
                dao.addTwittoToCache({
                    id: loginResult.userId,
                    username: loginResult.screenName,
                }),
            ]);
            const category = await dao.getUserDao(userId).enable();

            void dao.userEventDao.logWebEvent(
                userId,
                WebEvent.addDmAccount,
                ctx.ip,
                loginResult.screenName,
                loginResult.userId
            );
            if (userId !== loginResult.userId) {
                void dao.userEventDao.logWebEvent(
                    loginResult.userId,
                    WebEvent.addedAsSomeonesDmAccount,
                    ctx.ip,
                    session.username,
                    userId
                );
            }

            await queue.add(
                'sendWelcomeMessage',
                {
                    id: Date.now(), // otherwise some seem stuck??
                    userId,
                    username: ctx.session.username,
                },
                { delay: 500 }
            ); // otherwise it looks like it weirdly may start before setUserParams finished :/

            const params = await dao.getUserDao(session.userId).getUserParams();
            const country = geoip.lookup(ctx.ip)?.country;
            const msgContent = encodeURI(
                JSON.stringify({
                    userId: session.userId,
                    username: session.username,
                    dmUsername: loginResult.screenName,
                    category,
                    lang: params.lang,
                    country,
                    priceTags: getPriceTags(country),
                    isPro: Number(params.pro) > 0,
                    friendCodes:
                        params.pro === '2' ? await dao.getUserDao(session.userId).getFriendCodesWithUsername() : null,
                    hasSubscription: Boolean(params.customerId),
                })
            );

            ctx.type = 'html';
            ctx.body = `You successfully logged in! closing this window...
      <script>
        window.opener && window.opener.postMessage({msg: 'step2', content: "${msgContent}"}, '${process.env.WEB_URL}');
        close();
      </script>`;
        });
}


================================================
FILE: unfollow-ninja-server/src/api/stripe.ts
================================================
import type { Queue } from 'bull';
import type { ParameterizedContext } from 'koa';
import Stripe from 'stripe';
import { getAllInfoByISO } from 'iso-country-currency';

import { WebEvent } from '../dao/userEventDao';
import { UserCategory } from '../dao/dao';
import type Dao from '../dao/dao';

const stripe = process.env.STRIPE_SK
    ? new Stripe(process.env.STRIPE_SK, {
          apiVersion: '2022-08-01',
      })
    : null;

export const handleWebhook = async (ctx: ParameterizedContext, dao: Dao, bullQueue: Queue) => {
    const endpointSecret = process.env.STRIPE_WH_SECRET;
    if (!stripe || !endpointSecret) {
        return ctx.throw(404);
    }
    const signature = ctx.request.headers['stripe-signature'];
    let event;
    try {
        event = stripe.webhooks.constructEvent(ctx.request['rawBody'], signature, endpointSecret);
    } catch (err) {
        return ctx.throw(400);
    }
    const subscription = event.data.object as Stripe.Subscription;
    const { userId } = subscription.metadata;

    switch (event.type) {
        case 'customer.subscription.created':
        case 'customer.subscription.updated':
            if (subscription.status === 'active' || subscription.status === 'trialing') {
                // enable pro
                const plan = 'prod_KjHvT2vhr2F7tA' === subscription.items.data[0].plan.product ? 'friends' : 'pro';
                await enablePro(dao, bullQueue, userId, plan, ctx.ip, subscription.id);
                const customerId = subscription.customer as string;
                await dao.getUserDao(userId).setUserParams({ customerId });
                await stripe.customers.update(customerId, {
                    metadata: subscription.metadata,
                });
            } else {
                await disablePro(dao, userId, ctx.ip, subscription.id);
            }
            break;
        case 'customer.subscription.deleted':
            await disablePro(dao, userId, ctx.ip, subscription.id);
            break;
    }
    ctx.status = 204;
};

export const enablePro = async (
    dao: Dao,
    queue: Queue,
    userId: string,
    plan: 'friends' | 'pro',
    ip: string,
    subscriptionId: string
) => {
    const userDao = dao.getUserDao(userId);
    const username = await dao.getCachedUsername(userId);

    const wasPro = userDao.isPro();
    if (plan === 'friends') {
        void dao.userEventDao.logWebEvent(userId, WebEvent.enableFriends, ip, username, subscriptionId);
        await userDao.setUserParams({ pro: '2' });
        await userDao.addFriendCodes();
    } else {
        void dao.userEventDao.logWebEvent(userId, WebEvent.enablePro, ip, username, subscriptionId);
        await userDao.setUserParams({ pro: '1' });
        await disableFriendCodes(dao, userId, ip); // in case the update is a downgrade
    }
    if (UserCategory.enabled === (await userDao.getCategory())) {
        await userDao.setCategory(UserCategory.vip);
    }

    if (!wasPro) {
        await queue.add('sendWelcomeMessage', {
            id: Date.now(),
            userId,
            username,
            isPro: true,
        });
    }
};

export const disablePro = async (dao: Dao, userId: string, ip: string, subscriptionId: string) => {
    const userDao = dao.getUserDao(userId);
    const username = await dao.getCachedUsername(userId);

    await dao.userEventDao.logWebEvent(userId, WebEvent.disablePro, ip, username, subscriptionId);
    await userDao.setUserParams({ pro: '0' });
    await disableFriendCodes(dao, userId, ip);
    if (UserCategory.vip === (await userDao.getCategory())) {
        await userDao.setCategory(UserCategory.enabled);
    }
};

const disableFriendCodes = async (dao: Dao, userId: string, ip: string) => {
    const userDao = dao.getUserDao(userId);
    for (const code of await userDao.getFriendCodes()) {
        if (code.friendId) {
            // disable pro for friends too
            const friendUsername = await dao.getCachedUsername(code.friendId);
            void dao.userEventDao.logWebEvent(
                userId,
                WebEvent.disableFriendRegistration,
                ip,
                friendUsername,
                code.friendId
            );
            void dao.userEventDao.logWebEvent(
                code.friendId,
                WebEvent.disableFriendRegistration,
                ip,
                friendUsername,
                code.userId
            );
            await dao.getUserDao(code.friendId).setUserParams({ pro: '0' });
            if (UserCategory.vip === (await dao.getUserDao(code.friendId).getCategory())) {
                await dao.getUserDao(code.friendId).setCategory(UserCategory.enabled);
            }
        }
        await userDao.deleteFriendCodes(code.code);
    }
};

export const generateProCheckoutUrl = async (
    countryCode: string,
    plan: 'pro' | 'friends',
    userId: string,
    username: string
) => {
    if (!stripe) {
        return null;
    }
    const price = getPrice(countryCode);
    const stripeSession = await stripe.checkout.sessions.create({
        billing_address_collection: 'auto',
        line_items: [
            {
                price: plan === 'friends' ? price.friendsId : price.proId,
                quantity: 1,
            },
        ],
        mode: 'subscription',
        subscription_data: {
            metadata: {
                userId,
                username,
            },
            trial_period_days: 10,
        },
        metadata: {
            userId,
            username,
        },
        allow_promotion_codes: false,
        success_url: `${process.env.WEB_URL}`,
        cancel_url: `${process.env.WEB_URL}`,
    });
    return stripeSession.url;
};

export const getManageSubscriptionUrl = async (dao: Dao, userId: string) => {
    if (!stripe) {
        return null;
    }
    const customer = (await dao.getUserDao(userId).getUserParams()).customerId;
    if (!customer) {
        return null;
    }
    return (await stripe.billingPortal.sessions.create({ customer })).url;
};

interface Price {
    pro: number | string;
    proId: string;
    friends: number | string;
    friendsId: string;
    name: string;
}

const PRICES: Record<string, Price> = {
    USD: {
        pro: 3,
        proId: 'price_1K2j6qEwrjMfujSGZiTUPDH9',
        friends: '5.99',
        friendsId: 'price_1MEILOEwrjMfujSGgFOJapDB',
        name: 'dollars',
    },
    EUR: {
        pro: '2.50',
        proId: 'price_1K58S6EwrjMfujSGdHu2h1k8',
        friends: '4.99',
        friendsId: 'price_1MEIN4EwrjMfujSGl6qMokU5',
        name: 'euros',
    },
    IDR: {
        pro: '10 000',
        proId: 'price_1KBRhYEwrjMfujSGy1mftPTe',
        friends: '29 000',
        friendsId: 'price_1MEIPkEwrjMfujSGHk9vU8JU',
        name: 'rupiah',
    },
    PHP: {
        pro: 120,
        proId: 'price_1K58ddEwrjMfujSGGoga3KB7',
        friends: 190,
        friendsId: 'price_1K58dsEwrjMfujSGyAXvzoMz',
        name: 'pesos',
    },
    BRL: {
        pro: 10,
        proId: 'price_1KBRgzEwrjMfujSGy158RWWu',
        friends: 15,
        friendsId: 'price_1KBRdPEwrjMfujSGxfIMGqYq',
        name: 'reais',
    },
    GBP: {
        pro: 2,
        proId: 'price_1K58isEwrjMfujSGhUcEq77E',
        friends: '3.50',
        friendsId: 'price_1K58jcEwrjMfujSG5qXVO8mB',
        name: 'pounds',
    },
};

const getPrice = (countryCode: string) => {
    let currency;
    try {
        currency = getAllInfoByISO(countryCode).currency;
    } catch {
        currency = 'USD';
    }
    return PRICES[currency] || PRICES.USD;
};

export const getPriceTags = (countryCode: string) => {
    const price = getPrice(countryCode);
    return {
        pro: price.pro + ' ' + price.name,
        friends: price.friends + ' ' + price.name,
    };
};


================================================
FILE: unfollow-ninja-server/src/api/user.ts
================================================
import Router from 'koa-router';
import type { Queue } from 'bull';
import geoip from 'geoip-country';

import type Dao from '../dao/dao';
import type { NinjaSession } from '../api';
import { UserCategory } from '../dao/dao';
import { WebEvent } from '../dao/userEventDao';
import { SUPPORTED_LANGUAGES_CONST } from '../utils/utils';
import { generateProCheckoutUrl, getManageSubscriptionUrl } from './stripe';

export function createUserRouter(dao: Dao, queue: Queue) {
    return (
        new Router()
            .use(async (ctx, next) => {
                const session = ctx.session as NinjaSession;
                if (!session.userId) {
                    await ctx.throw(401);
                    return;
                }
                await next();
            })
            .post('/disable', async (ctx) => {
                const session = ctx.session as NinjaSession;
                await dao.getUserDao(session.userId).setCategory(UserCategory.disabled);
                await dao.getUserDao(session.userId).setUserParams({
                    dmId: null,
                    dmToken: null,
                    dmTokenSecret: null,
                });
                void dao.userEventDao.logWebEvent(session.userId, WebEvent.disable, ctx.ip, session.username);
                ctx.status = 204;
            })
            .post('/logout', async (ctx) => {
                const session = ctx.session as NinjaSession;
                void dao.userEventDao.logWebEvent(session.userId, WebEvent.logout, ctx.ip, session.username);
                session.userId = null;
                session.username = null;
                ctx.status = 204;
            })
            .put('/lang', async (ctx) => {
                const session = ctx.session as NinjaSession;
                const lang = ctx.request['body']?.['lang'];
                if (!SUPPORTED_LANGUAGES_CONST.includes(lang)) {
                    await ctx.throw(400);
                    return;
                }
                await dao.getUserDao(session.userId).setUserParams({ lang });
                void dao.userEventDao.logWebEvent(session.userId, WebEvent.setLang, ctx.ip, session.username, lang);

                await queue.add('sendWelcomeMessage', {
                    id: Date.now(), // otherwise some seem stuck??
                    userId: session.userId,
                    username: session.username,
                });

                ctx.status = 204;
            })
            .put('/registerFriendCode', async (ctx) => {
                // /!\ to rate limit
                const session = ctx.session as NinjaSession;
                const code = ctx.request['body']?.['code'];

                void dao.userEventDao.logWebEvent(
                    session.userId,
                    WebEvent.tryFriendCode,
                    ctx.ip,
                    session.username,
                    code
                );
                if (code.length !== 6) {
                    ctx.throw(400);
                    return;
                }
                const success = await dao.getUserDao(session.userId).registerFriendCode(code);
                if (!success) {
                    ctx.throw(404);
                    return;
                }

                void dao.userEventDao.logWebEvent(
                    session.userId,
                    WebEvent.registeredAsFriend,
                    ctx.ip,
                    session.username,
                    code
                );
                await dao.getUserDao(session.userId).setUserParams({ pro: '3' });
                await dao.getUserDao(session.userId).setCategory(UserCategory.vip);

                await queue.add('sendWelcomeMessage', {
                    id: Date.now(), // otherwise some seem stuck??
                    userId: session.userId,
                    username: session.username,
                    isPro: true,
                });

                ctx.status = 204;
            })
            /*.get('/buy-pro', async (ctx) => {
            const session = ctx.session as NinjaSession;
            const country = geoip.lookup(ctx.ip)?.country;
            const checkoutUrl = await generateProCheckoutUrl(country, 'pro', session.userId, session.username);
            if (!checkoutUrl) {
                // stripe disabled
                ctx.throw(404);
                return;
            }

            ctx.redirect(checkoutUrl);
        })*/
            .get('/buy-friends', async (ctx) => {
                const session = ctx.session as NinjaSession;
                const country = geoip.lookup(ctx.ip)?.country;
                const checkoutUrl = await generateProCheckoutUrl(country, 'friends', session.userId, session.username);
                if (!checkoutUrl) {
                    // stripe disabled
                    ctx.throw(404);
                    return;
                }

                ctx.redirect(checkoutUrl);
            })
            .get('/manage-subscription', async (ctx) => {
                const session = ctx.session as NinjaSession;
                const manageSubscriptionUrl = await getManageSubscriptionUrl(dao, session.userId);
                if (!manageSubscriptionUrl) {
                    // stripe disabled or no customerId
                    ctx.body = 'No subscription could be found on this account.';
                    ctx.status = 404;
                    return;
                }

                ctx.redirect(manageSubscriptionUrl);
            })
            .get('/latest-notifications', async (ctx) => {
                const session = ctx.session as NinjaSession;
                const events = await dao.userEventDao.getNotificationEvents(session.userId, 30, 0);

                ctx.body = events.map((event) => ({
                    sentAt: event.createdAt,
                    message: event.message,
                }));
            })
    );
}


================================================
FILE: unfollow-ninja-server/src/api.ts
================================================
import 'dotenv/config';

import * as Sentry from '@sentry/node';
import '@sentry/tracing';
import Koa from 'koa';
import Router from 'koa-router';
import koaSession from 'koa-session';
import koaBodyParser from 'koa-bodyparser';
import koaCors from '@koa/cors';
import Bull from 'bull';
import geoip from 'geoip-country';

import Dao, { UserCategory } from './dao/dao';
import logger, { setLoggerPrefix } from './utils/logger';
import { createAuthRouter } from './api/auth';
import { createAdminRouter } from './api/admin';
import { createUserRouter } from './api/user';
import { getPriceTags, handleWebhook } from './api/stripe';

function assertEnvVariable(name: string) {
    if (typeof process.env[name] === 'undefined') {
        logger.error(`Some required environment variables are missing (${name}).`);
        logger.error('Make sure you added them in a .env file in you cwd or that you defined them.');
        process.exit();
    }
}
assertEnvVariable('WEB_URL');
assertEnvVariable('COOKIE_SIGNING_KEY');

setLoggerPrefix('api');

const SENTRY_DSN = process.env.SENTRY_DSN_API || undefined;
if (SENTRY_DSN) {
    Sentry.init({ dsn: SENTRY_DSN, tracesSampleRate: 0.1 });
}

assertEnvVariable('REDIS_URI');
assertEnvVariable('POSTGRES_URI');
const dao = new Dao();
const bullQueue = new Bull('ninja', process.env.REDIS_BULL_URI, {
    defaultJobOptions: {
        attempts: 3,
        backoff: 60000,
        removeOnComplete: true,
        removeOnFail: true,
    },
});
bullQueue.on('error', (err) => {
    logger.error('Bull error: ' + err.stack);
    Sentry.captureException(err);
});

const authRouter = createAuthRouter(dao, bullQueue);
const userRouter = createUserRouter(dao, bullQueue);
const adminRouter = createAdminRouter(dao, bullQueue);

export interface NinjaSession {
    twitterTokenSecret?: Record<string, string>;
    userId?: string;
    username?: string;
}

const router = new Router()
    .get('/', (ctx) => {
        ctx.body = { status: 'ᕕ( ᐛ )ᕗ Hello, fellow human' };
    })
    .get('/robots.txt', (ctx) => {
        ctx.body = 'User-agent: *\nDisallow: /';
    })
    .use('/auth', authRouter.routes(), authRouter.allowedMethods())
    .use('/admin', adminRouter.routes(), adminRouter.allowedMethods())
    .all(
        '/(.*)',
        koaCors({
            // only routes below are allowed for CORS
            origin: process.env.WEB_URL,
            credentials: true,
        })
    )
    .use('/user', userRouter.routes(), userRouter.allowedMethods())
    .get('/get-status', async (ctx) => {
        const session = ctx.session as NinjaSession;
        if (!session.userId) {
            ctx.body = {
                country: geoip.lookup(ctx.ip)?.country,
            };
        } else {
            const [params, category] = await Promise.all([
                dao.getUserDao(session.userId).getUserParams(),
                dao.getUserDao(session.userId).getCategory(),
            ]);
            const country = geoip.lookup(ctx.ip)?.country;
            ctx.body = {
                userId: session.userId,
                username: session.username,
                dmUsername:
                    params.dmId && [UserCategory.enabled, UserCategory.vip].includes(category)
                        ? await dao.getCachedUsername(params.dmId)
                        : null,
                category,
                lang: params.lang,
                country,
                priceTags: getPriceTags(country),
                isPro: Number(params.pro) > 0,
                friendCodes:
                    params.pro === '2' ? await dao.getUserDao(session.userId).getFriendCodesWithUsername() : null,
                hasSubscription: Boolean(params.customerId),
            };
        }
    })
    .post('/stripe-webhook', (ctx) => handleWebhook(ctx, dao, bullQueue));

// Create the server app with its router/log/session and error management
const app = new Koa();
app.keys = [process.env.COOKIE_SIGNING_KEY]; // random key used to sign cookies
app.proxy = true;
app.use(async (ctx, next) => {
    const start = Date.now();
    await next();
    logger.info(`${ctx.method} ${ctx.url} - ${Date.now() - start}ms`);
})
    .use(
        koaSession(
            {
                store: {
                    get: (key) => dao.getSession(key),
                    set: (key, sess) => dao.setSession(key, sess),
                    destroy: (key) => dao.deleteSession(key),
                },
                maxAge: 3600000, // 1h
            },
            app
        )
    )
    .use(koaBodyParser())
    .use(router.routes())
    .use(router.allowedMethods())
    .on('error', (err, ctx) => {
        logger.error(err.stack);
        Sentry.withScope((scope) => {
            scope.addEventProcessor((event) => {
                return Sentry.Handlers.parseRequest(event, ctx.request);
            });
            Sentry.captureException(err);
        });
    });

logger.info('Connecting to the databases...');
dao.load()
    .then(() => {
        app.listen(4000);
        logger.info(`🚀 Server ready at http://localhost:4000`);
    })
    .catch((err) => {
        Sentry.captureException(err);
        logger.error(err);
    });


================================================
FILE: unfollow-ninja-server/src/dao/dao.ts
================================================
import Redis from 'ioredis';
import { DataTypes, Model, Sequelize } from 'sequelize';
import type { ModelStatic } from 'sequelize/types/model';
import cluster from 'cluster';

import { ITwittoInfo, IUserEgg, IUserParams, Session } from '../utils/types';
import UserDao from './userDao';
import UserEventDao from './userEventDao';

export enum UserCategory {
    enabled,
    suspended,
    revoked,
    disabled,
    dmclosed,
    accountClosed,
    vip,
}

interface ICachedUsername extends Model {
    twitterId: string;
    username: string;
}
export interface IFriendCode extends Model {
    code: string;
    userId: string;
    friendId?: string;
}

export default class Dao {
    public readonly redis: Redis;
    public readonly sequelize: Sequelize;
    public readonly sequelizeLogs: Sequelize;
    public readonly sequelizeFollowers: Sequelize;
    public readonly userEventDao: UserEventDao;

    private readonly CachedUsername: ModelStatic<ICachedUsername>;
    public readonly FriendCode: ModelStatic<IFriendCode>;

    constructor(
        redis = new Redis(process.env.REDIS_URI, { lazyConnect: true }),
        sequelize = new Sequelize(process.env.POSTGRES_URI, {
            logging: false,
            dialectOptions: {
                application_name: 'UnfollowMonkey - ' + (cluster.worker ? `worker ${cluster.worker.id}` : 'master'),
                statement_timeout: 30000,
            },
        }),
        sequelizeLogs = new Sequelize(process.env.POSTGRES_LOGS_URI, {
            logging: false,
            dialectOptions: {
                application_name: 'UnfollowMonkey - ' + (cluster.worker ? `worker ${cluster.worker.id}` : 'master'),
                statement_timeout: 30000,
            },
        }),
        sequelizeFollowers = new Sequelize(process.env.POSTGRES_FOLLOWERS_URI, {
            logging: false,
            dialectOptions: {
                application_name: 'UnfollowMonkey - ' + (cluster.worker ? `worker ${cluster.worker.id}` : 'master'),
                statement_timeout: 30000,
            },
        })
    ) {
        this.redis = redis;
        this.sequelize = sequelize;
        this.sequelizeLogs = sequelizeLogs;
        this.sequelizeFollowers = sequelizeFollowers;

        this.CachedUsername = this.sequelize.define('CachedUsername', {
            twitterId: {
                type: DataTypes.STRING(30),
                allowNull: false,
                primaryKey: true,
            },
            username: { type: DataTypes.STRING(20), allowNull: false },
        });
        this.FriendCode = this.sequelize.define(
            'FriendCode',
            {
                code: { type: DataTypes.STRING(6), allowNull: false, primaryKey: true },
                userId: { type: DataTypes.STRING(30), allowNull: false },
                friendId: { type: DataTypes.STRING(30), allowNull: true },
            },
            {
                indexes: [{ fields: ['userId'] }, { fields: ['friendId'] }],
            }
        );
        this.userEventDao = new UserEventDao(this);
    }

    /**
     * Wait for the databases to be connected, and create the tables if necessary
     */
    public async load(): Promise<Dao> {
        await Promise.all([
            // check that postgresql is connected
            await this.sequelize.authenticate(),
            await this.sequelizeLogs.authenticate(),
        ]);
        await this.CachedUsername.sync(); // create the missing postgresql tables
        await this.FriendCode.sync();
        await this.userEventDao.createTables();
        await this.getUserDao('').createTables();
        await this.redis.connect(); // wait for redis to load its data
        return this;
    }

    public async disconnect() {
        this.redis.disconnect();
        await Promise.all([this.sequelize.close(), this.sequelizeLogs.close()]);
    }

    public getUserDao(userId: string) {
        return new UserDao(userId, this);
    }

    public async addUser(userEgg: IUserEgg): Promise<void> {
        const {
            id,
            category,
            username,
            added_at,
            lang,
            token,
            tokenSecret,
            dmId,
            dmToken,
            dmTokenSecret,
            pro,
            customerId,
        } = userEgg;
        const params: IUserParams = {
            added_at,
            lang,
            token,
            tokenSecret,
            dmId,
            dmToken,
            dmTokenSecret,
            pro,
            customerId,
        };
        await Promise.all([
            this.redis.zadd('users', category.toString(), id),
            this.redis.hmset(`user:${id}`, params),
            this.addTwittoToCache({ id, username }),
        ]);
    }

    public async getUserIds(): Promise<string[]> {
        return this.redis.zrange('users', 0, -1);
    }

    public async getUserIdsByCategory(category: UserCategory): Promise<string[]> {
        return this.redis.zrangebyscore('users', category, category);
    }

    public async getUserCountByCategory(): Promise<Record<UserCategory, number>> {
        const nbCategory = Object.keys(UserCategory).length / 2; // not super clean but I have no better idea
        const counts = await Promise.all(
            new Array(nbCategory).fill(null).map((_, category) => this.redis.zcount('users', category, category))
        );
        return Object.fromEntries(counts.map((count, category) => [category, count])) as Record<UserCategory, number>;
    }

    public async getCachedUsername(userId: string): Promise<string> {
        return (await this.CachedUsername.findByPk(userId, { attributes: ['username'] }))?.username || null;
    }

    public async getCachedUserId(username: string): Promise<string> {
        return (
            (await this.CachedUsername.findOne({ where: { username }, attributes: ['twitterId'] }))?.twitterId || null
        );
    }

    public async addTwittoToCache(twittoInfo: ITwittoInfo): Promise<void> {
        const { id, username } = twittoInfo;
        if (username.length > 20 && username.startsWith('erased_')) {
            return; // these are weird deleted users 'erased_{userid}'
        }
        await this.CachedUsername.upsert({ twitterId: id, username }, { returning: false });
    }

    public async getSession(uid: string): Promise<Session> {
        return JSON.parse((await this.redis.get(`session:${uid}`)) || '{}');
    }

    public async setSession(uid: string, params: Record<string, string>): Promise<void> {
        await this.redis.set(`session:${uid}`, JSON.stringify(params));
        await this.redis.expire(`session:${uid}`, 3600); // 1h sessions
    }

    public async deleteSession(uid: string): Promise<void> {
        await this.redis.del(`session:${uid}`);
    }

    public async getTokenSecret(token: string): Promise<string> {
        return (await this.redis.get(`tokensecret:${token}`)) || null;
    }

    public async setTokenSecret(token: string, secret: string): Promise<void> {
        await this.redis.set(`tokensecret:${token}`, secret);
        await this.redis.expire(`tokensecret:${token}`, 1200); // 20min memory (lasts <10min on twitter side)
    }
}


================================================
FILE: unfollow-ninja-server/src/dao/userDao.ts
================================================
import Redis from 'ioredis';
import Twit from 'twit';
import { TwitterApi } from 'twitter-api-v2';
import crypto from 'crypto';
import { DataTypes, InferAttributes, InferCreationAttributes, Model, Op } from 'sequelize';
import type { ModelStatic } from 'sequelize/types/model';

import type { default as Dao, IFriendCode } from './dao';
import { UserCategory } from './dao';
import type { IUserParams, Lang } from '../utils/types';
import { twitterCursorToTime } from '../utils/utils';

interface ITemporaryFollowerList
    extends Model<InferAttributes<ITemporaryFollowerList>, InferCreationAttributes<ITemporaryFollowerList>> {
    userId: string;
    nextCursor: string;
    followers: string;
}

interface IFollowersDetail extends Model<InferAttributes<IFollowersDetail>, InferCreationAttributes<IFollowersDetail>> {
    userId: string;
    followerId: string;
    followDetected: number;
    snowflakeId: string;
    uncachable: boolean;
}

export default class UserDao {
    private readonly redis: Redis;
    private readonly dao: Dao;
    private readonly userId: string;

    private readonly temporaryFollowerList: ModelStatic<ITemporaryFollowerList>;
    private readonly followersDetail: ModelStatic<IFollowersDetail>;

    constructor(userId: string, dao: Dao) {
        this.userId = userId;
        this.dao = dao;
        this.redis = dao.redis;

        this.temporaryFollowerList = dao.sequelize.define(
            'temporaryFollowerList',
            {
                userId: { type: DataTypes.STRING(30), allowNull: false, primaryKey: true },
                nextCursor: { type: DataTypes.STRING(20), allowNull: false },
                followers: { type: DataTypes.TEXT, allowNull: false },
            },
            {
                timestamps: true,
            }
        );

        this.followersDetail = dao.sequelizeFollowers.define(
            'followersDetail',
            {
                userId: { type: DataTypes.STRING(30), allowNull: false, primaryKey: true },
                followerId: { type: DataTypes.STRING(30), allowNull: false, primaryKey: true },
                followDetected: { type: DataTypes.INTEGER, allowNull: true },
                snowflakeId: { type: DataTypes.STRING(30), allowNull: true },
                uncachable: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
            },
            {
                timestamps: false,
                indexes: [{ fields: ['userId'] }],
            }
        );
    }

    public async createTables() {
        await this.temporaryFollowerList.sync();
        await this.followersDetail.sync();
    }

    public getUsername(): Promise<string> {
        return this.dao.getCachedUsername(this.userId);
    }

    public async getCategory(): Promise<UserCategory> {
        return Number((await this.redis.zscore('users', this.userId)) ?? 3); // default = disabled
    }

    public async setCategory(category: UserCategory): Promise<void> {
        await Promise.all([
            this.dao.userEventDao.logCategoryEvent(this.userId, category, await this.getCategory()),
            this.redis.zadd('users', category.toString(), this.userId),
        ]);
    }

    public async enable(): Promise<UserCategory.enabled | UserCategory.vip> {
        const proParam = await this.redis.hget(`user:${this.userId}`, 'pro');
        if (Number(proParam) > 0) {
            await this.setCategory(UserCategory.vip);
            return UserCategory.vip;
        } else {
            await this.setCategory(UserCategory.enabled);
            return UserCategory.enabled;
        }
    }

    // get the minimum timestamp required to do the next followers check
    // e.g if there are not enough requests left, it's twitter's next reset time
    // e.g if a check needs 4 requests, it's probably in 3min30 (twitter limit = 15/15min)
    // (default: 0)
    public async getNextCheckTime(): Promise<number> {
        return this.redis.get(`nextCheckTime:${this.userId}`).then((nextCheckTime) => Number(nextCheckTime));
    }

    // see above
    public async setNextCheckTime(nextCheckTime: number | string): Promise<void> {
        await this.redis.set(`nextCheckTime:${this.userId}`, nextCheckTime.toString());
    }

    // for big accounts (>150k), we need to scrap the followers in multiple chunks every 15min
    public async getTemporaryFollowerList(): Promise<{ nextCursor: string; followers: string[] } | null> {
        const followerList = await this.temporaryFollowerList.findByPk(this.userId, {
            attributes: ['nextCursor', 'followers'],
        });
        return followerList && { nextCursor: followerList.nextCursor, followers: followerList.followers.split(',') };
    }

    // see above
    public async setTemporaryFollowerList(nextCursor: string, followers: string[]): Promise<void> {
        await this.temporaryFollowerList.upsert(
            { userId: this.userId, nextCursor, followers: followers.join(',') },
            { returning: false }
        );
    }

    // see above
    public async deleteTemporaryFollowerList(): Promise<void> {
        await this.temporaryFollowerList.destroy({ where: { userId: this.userId } });
    }

    public async getUserParams(): Promise<IUserParams> {
        const stringUserParams = (await this.redis.hgetall(`user:${this.userId}`)) as Record<keyof IUserParams, string>;
        return {
            ...stringUserParams,
            added_at: parseInt(stringUserParams.added_at, 10),
            lang: stringUserParams.lang as Lang,
            pro: (stringUserParams.pro || '0') as '3' | '2' | '1' | '0',
        };
    }

    public async setUserParams(userParams: Partial<IUserParams>): Promise<void> {
        await this.redis.hmset(`user:${this.userId}`, userParams);
    }

    public async getTwit(): Promise<Twit> {
        const [token, tokenSecret] = await this.redis.hmget(`user:${this.userId}`, 'token', 'tokenSecret');
        if (!token || !tokenSecret) {
            throw new Error("Tried to create a new Twit client but the user didn't have any credentials stored");
        }
        return new Twit({
            access_token: token,
            access_token_secret: tokenSecret,
            consumer_key: process.env.CONSUMER_KEY,
            consumer_secret: process.env.CONSUMER_SECRET,
        });
    }

    public async getTwitterApi(): Promise<TwitterApi> {
        const [token, tokenSecret] = await this.redis.hmget(`user:${this.userId}`, 'token', 'tokenSecret');
        if (!token || !tokenSecret) {
            throw new Error("Tried to create a new twitter client but the user didn't have any credentials stored");
        }
        return new TwitterApi({
            accessToken: token,
            accessSecret: tokenSecret,
            appKey: process.env.CONSUMER_KEY,
            appSecret: process.env.CONSUMER_SECRET,
        });
    }

    public async getDmTwit(): Promise<Twit> {
        const [dmToken, dmTokenSecret] = await this.redis.hmget(`user:${this.userId}`, 'dmToken', 'dmTokenSecret');
        if (!dmToken || !dmTokenSecret) {
            throw new Error("Tried to create a new Twit DM client but the user didn't have any DM credentials stored");
        }
        return new Twit({
            access_token: dmToken,
            access_token_secret: dmTokenSecret,
            consumer_key: process.env.DM_CONSUMER_KEY,
            consumer_secret: process.env.DM_CONSUMER_SECRET,
        });
    }

    public async getDmTwitterApi(): Promise<TwitterApi> {
        const [dmToken, dmTokenSecret] = await this.redis.hmget(`user:${this.userId}`, 'dmToken', 'dmTokenSecret');
        if (!dmToken || !dmTokenSecret) {
            throw new Error("Tried to create a new Twit DM client but the user didn't have any DM credentials stored");
        }
        return new TwitterApi({
            accessToken: dmToken,
            accessSecret: dmTokenSecret,
            appKey: process.env.DM_CONSUMER_KEY,
            appSecret: process.env.DM_CONSUMER_SECRET,
        });
    }

    public async getLang(): Promise<Lang> {
        return (await this.redis.hget(`user:${this.userId}`, 'lang')) as Lang;
    }

    public async isPro(): Promise<boolean> {
        return Number(await this.redis.hget(`user:${this.userId}`, 'pro')) > 0;
    }

    public getDmId(): Promise<string> {
        return this.redis.hget(`user:${this.userId}`, 'dmId');
    }

    // list of follower IDs stored during last checkFollowers (in Twitter's order)
    // return null if there are no IDs
    public async getFollowers(): Promise<string[]> {
        return JSON.parse(await this.redis.get(`followers:${this.userId}`));
    }

    public async updateFollowers(
        followers: string[], // every follower, in Twitter's order
        newFollowers: string[], // followers to add
        unfollowers: string[], // followers to remove
        addedTime: number // timestamp in ms for new followers
    ): Promise<void> {
        // insert chunks of 100 new followers
        const newFollowersChunks = Array.from({ length: Math.ceil(newFollowers.length / 100) }, (v, i) =>
            newFollowers.slice(i * 100, i * 100 + 100)
        );
        for (const chunk of newFollowersChunks) {
            await this.followersDetail.bulkCreate(
                chunk.map((followerId) => ({
                    userId: this.userId,
                    followerId,
                    followDetected: addedTime / 1000 || null,
                })),
                { returning: false, ignoreDuplicates: true }
            );
        }

        // remove chunks of 100 unfollowers
        const unfollowersChunks = Array.from({ length: Math.ceil(unfollowers.length / 100) }, (v, i) =>
            unfollowers.slice(i * 100, i * 100 + 100)
        );
        for (const chunk of unfollowersChunks) {
            await this.followersDetail.destroy({ where: { userId: this.userId, followerId: chunk } });
        }

        await Promise.all([
            this.redis.set(`followers:${this.userId}`, JSON.stringify(followers)),
            this.redis.set(`followers:count:${this.userId}`, followers.length.toString()),
            unfollowers.length > 0 && this.redis.incrby('total-unfollowers', unfollowers.length),
        ]);
    }

    public async setFollowerSnowflakeId(followerId: string, snowflakeId: string): Promise<void> {
        await this.followersDetail.update(
            { snowflakeId },
            { where: { userId: this.userId, followerId }, returning: false }
        );
    }

    // get twitter cached snowflakeId (containing the follow timing information)
    // returns null if not cached yet
    public async getFollowerSnowflakeId(followerId: string): Promise<string | null> {
        return (
            (
                await this.followersDetail.findOne({
                    where: { userId: this.userId, followerId },
                    attributes: ['snowflakeId'],
                })
            )?.snowflakeId || null
        );
    }

    // Some followers ids weirdly can't be cached (disabled?)
    public async getUncachableFollowers(): Promise<string[]> {
        return (
            await this.followersDetail.findAll({
                where: { userId: this.userId, uncachable: true },
                attributes: ['followerId'],
            })
        ).map((row) => row.followerId);
    }

    public async addUncachableFollower(followerId: string): Promise<void> {
        await this.followersDetail.update(
            { uncachable: true },
            { where: { userId: this.userId, followerId }, returning: false }
        );
    }

    // get the timestamp (in ms) when the follower followed the user.
    // determined from the cached snowflakeId or from the time it was added in DB
    public async getFollowTime(followerId: string): Promise<number> {
        return (
            twitterCursorToTime(await this.getFollowerSnowflakeId(followerId)) || this.getFollowDetectedTime(followerId)
        );
    }

    // get the timestamp when the follower was added to the db (in ms)
    public async getFollowDetectedTime(followerId: string): Promise<number | null> {
        return (
            (
                await this.followersDetail.findOne({
                    where: { userId: this.userId, followerId },
                    attributes: ['followDetected'],
                })
            )?.followDetected * 1000 || null
        );
    }

    // return true if some followers were never cached by cacheFollowers
    public async getHasNotCachedFollowers(): Promise<boolean> {
        return (
            Number(await this.redis.get(`followers:count:${this.userId}`)) < 30000 &&
            Boolean(
                await this.followersDetail.findOne({
                    where: { userId: this.userId, snowflakeId: { [Op.is]: null }, uncachable: false },
                    attributes: ['userId'],
                    limit: 1,
                })
            )
        );
    }

    public async getCachedFollowers(): Promise<string[]> {
        const cachedFollowers = (
            await this.followersDetail.findAll({
                where: { userId: this.userId, snowflakeId: { [Op.not]: null } },
                order: ['followerId'],
                offset: 0,
                limit: 5000,
                attributes: ['followerId'],
            })
        ).map((row) => row.followerId);

        // iterate if > 5000 followers (to avoid long queries)
        let nextCachedFollowers = cachedFollowers;
        let offset = 5000;
        while (nextCachedFollowers.length === 5000) {
            nextCachedFollowers = (
                await this.followersDetail.findAll({
                    where: { userId: this.userId, snowflakeId: { [Op.not]: null } },
                    order: ['followerId'],
                    offset,
                    limit: 5000,
                    attributes: ['followerId'],
                })
            ).map((row) => row.followerId);
            cachedFollowers.push(...nextCachedFollowers);
            offset += 5000;
        }
        return cachedFollowers;
    }

    public async getFriendCodes(): Promise<IFriendCode[]> {
        return await this.dao.FriendCode.findAll({
            where: { userId: this.userId },
        });
    }

    public async getFriendCodesWithUsername(): Promise<{ code: string; friendUsername: string }[]> {
        return Promise.all(
            (await this.dao.FriendCode.findAll({ where: { userId: this.userId } })).map(async (code) => ({
                code: code.code,
                friendUsername: code.friendId && (await this.dao.getCachedUsername(code.friendId)),
            }))
        );
    }

    // Add friend codes until there are 5 of them
    public async addFriendCodes(): Promise<void> {
        const nbCodes = (await this.getFriendCodes()).length;
        if (nbCodes > 5) {
            throw new Error(this.userId + ' has more than 5 friend codes - should not happen');
        }
        for (let i = 0; i < 5 - nbCodes; ++i) {
            const code = crypto.randomBytes(3).toString('hex').toUpperCase();
            await this.dao.FriendCode.create({ userId: this.userId, code });
        }
    }

    public async deleteFriendCodes(code: string): Promise<void> {
        await this.dao.FriendCode.destroy({ where: { userId: this.userId, code } });
    }

    public async registerFriendCode(code: string): Promise<boolean> {
        const [nbUpdates] = await this.dao.FriendCode.update(
            { friendId: this.userId },
            { where: { code, friendId: null } }
        );
        return nbUpdates === 1;
    }

    public async getRegisteredFriendCode(): Promise<IFriendCode> {
        return await this.dao.FriendCode.findOne({
            where: { friendId: this.userId },
        });
    }

    public async getAllUserData() {
        const [
            username,
            category,
            nextCheckTime,
            userParams,
            followers,
            friendCodes,
            registeredFriendCode,
            temporaryFollowerList,
            followersDetail,
        ] = await Promise.all([
            this.getUsername(),
            this.getCategory(),
            this.getNextCheckTime(),
            this.getUserParams(),
            this.getFollowers(),
            this.getFriendCodes(),
            this.getRegisteredFriendCode(),
            this.getTemporaryFollowerList(),
            this.followersDetail.findAll({ where: { userId: this.userId }, raw: true }),
        ]);

        // while running unit tests, with sqlite, booleans are numbers.
        followersDetail.forEach((detail) => (detail.uncachable = Boolean(detail.uncachable)));

        return {
            username,
            category,
            nextCheckTime,
            userParams,
            followers,
            friendCodes,
            registeredFriendCode,
            temporaryFollowerList,
            followersDetail,
        };
    }

    // delete follower data about revoked users
    // to save some RAM space
    public async cleanUser(): Promise<void> {
        await this.redis.del(
            `nextCheckTime:${this.userId}`,
            `followers:${this.userId}`,
            `followers:count:${this.userId}`
        );
    }

    // Not safe (some tasks for that user may still exist)
    // But can be used for disabled account
    public async deleteUser(): Promise<void> {
        await Promise.all([
            this.redis.zrem(`users`, this.userId),
            this.redis.del(
                `nextCheckTime:${this.userId}`,
                `user:${this.userId}`,
                `followers:${this.userId}`,
                `followers:count:${this.userId}`
            ),
            this.dao.FriendCode.destroy({ where: { userId: this.userId } }),
            this.followersDetail.destroy({ where: { userId: this.userId } }),
            this.deleteTemporaryFollowerList(),
        ]);
    }
}


================================================
FILE: unfollow-ninja-server/src/dao/userEventDao.ts
================================================
import type { InferAttributes, InferCreationAttributes, Sequelize } from 'sequelize';
import { DataTypes, Model } from 'sequelize';
import type { ModelStatic } from 'sequelize/types/model';
import * as Sentry from '@sentry/node';

import type { default as Dao } from './dao';
import type { IUnfollowerInfo } from '../utils/types';
import { UserCategory } from './dao';
import Logger from '../utils/logger';

export enum WebEvent {
    createAccount,
    signIn,
    addDmAccount,
   
Download .txt
gitextract_xza4tmml/

├── .github/
│   └── workflows/
│       └── server-ci.yml
├── .gitignore
├── license.md
├── readme.md
├── unfollow-monkey-ui/
│   ├── .gitignore
│   ├── package.json
│   ├── public/
│   │   ├── _redirects
│   │   ├── favicon/
│   │   │   └── site.webmanifest
│   │   ├── index.html
│   │   ├── manifest.json
│   │   ├── privacy-policy.txt
│   │   ├── robots.txt
│   │   ├── sitemap.xml
│   │   └── tos.txt
│   └── src/
│       ├── App.js
│       ├── components/
│       │   ├── Faq.js
│       │   ├── Faq.module.scss
│       │   ├── Link.js
│       │   ├── MiniApp/
│       │   │   ├── LanguageSelector.js
│       │   │   ├── LanguageSelector.module.scss
│       │   │   ├── ProCard.js
│       │   │   └── ProCard.module.scss
│       │   ├── MiniApp.js
│       │   ├── MiniApp.module.scss
│       │   ├── Navbar.js
│       │   ├── Navbar.module.scss
│       │   ├── Repo.js
│       │   ├── Repo.module.scss
│       │   ├── Section.js
│       │   ├── Section.module.scss
│       │   └── index.js
│       ├── images/
│       │   ├── flags/
│       │   │   └── index.js
│       │   └── index.js
│       ├── index.js
│       ├── reportWebVitals.js
│       ├── service-worker.js
│       ├── serviceWorkerRegistration.js
│       ├── style.scss
│       └── twemojis/
│           ├── Emojis.js
│           └── Emojis.module.scss
├── unfollow-ninja-server/
│   ├── .dockerignore
│   ├── .eslintrc.json
│   ├── .prettierignore
│   ├── .prettierrc.json
│   ├── Dockerfile
│   ├── docker-compose.yml
│   ├── jest.config.js
│   ├── locales/
│   │   ├── ar.json
│   │   ├── de.json
│   │   ├── en.json
│   │   ├── es.json
│   │   ├── fr.json
│   │   ├── hy.json
│   │   ├── id.json
│   │   ├── nl.json
│   │   ├── pl.json
│   │   ├── pt.json
│   │   ├── pt_BR.json
│   │   ├── ru.json
│   │   ├── sk.json
│   │   ├── th.json
│   │   ├── tr.json
│   │   ├── uk.json
│   │   ├── zgh.json
│   │   └── zh_Hans.json
│   ├── package.json
│   ├── pm2.yml
│   ├── src/
│   │   ├── api/
│   │   │   ├── admin.ts
│   │   │   ├── auth.ts
│   │   │   ├── stripe.ts
│   │   │   └── user.ts
│   │   ├── api.ts
│   │   ├── dao/
│   │   │   ├── dao.ts
│   │   │   ├── userDao.ts
│   │   │   └── userEventDao.ts
│   │   ├── jobs/
│   │   │   ├── cleanUsersWithRevokedTokens.ts
│   │   │   ├── deleteRedisSnowflakeIds.ts
│   │   │   ├── emptyQueue.ts
│   │   │   ├── migrateCachedUsernamesFromRedisToPostres.ts
│   │   │   ├── migrateFollowersFromRedisToPostres.ts
│   │   │   ├── resetCachedSnowflakeIds.ts
│   │   │   ├── setUsersLanguage.ts
│   │   │   └── twitExperiment.ts
│   │   ├── tasks/
│   │   │   ├── index.ts
│   │   │   ├── notifyUser.ts
│   │   │   ├── reenableFollowers.ts
│   │   │   ├── sendWelcomeMessage.ts
│   │   │   ├── task.ts
│   │   │   └── updateMetrics.ts
│   │   ├── utils/
│   │   │   ├── logger.ts
│   │   │   ├── metrics.ts
│   │   │   ├── types.ts
│   │   │   └── utils.ts
│   │   ├── workers/
│   │   │   ├── cacheAllFollowers.ts
│   │   │   └── checkAllFollowers.ts
│   │   └── workers.ts
│   ├── tests/
│   │   ├── dao/
│   │   │   ├── __snapshots__/
│   │   │   │   └── userDao.spec.ts.snap
│   │   │   ├── dao.spec.ts
│   │   │   ├── userDao.spec.ts
│   │   │   └── userEventDao.spec.ts
│   │   ├── docker-compose.yml
│   │   ├── tasks/
│   │   │   ├── cacheFollowers.spec.ts.disabled
│   │   │   ├── checkFollowers.spec.ts.disabled
│   │   │   ├── notifyUser.spec.ts
│   │   │   └── sendWelcomeMessage.spec.ts
│   │   └── utils.ts
│   ├── tsconfig-build.json
│   └── tsconfig.json
└── unfollow-ninja-ui/
    ├── .gitignore
    ├── package.json
    ├── public/
    │   ├── _redirects
    │   ├── favicon/
    │   │   └── site.webmanifest
    │   ├── index.html
    │   ├── manifest.json
    │   ├── robots.txt
    │   └── sitemap.xml
    └── src/
        ├── App.js
        ├── components/
        │   ├── Faq.js
        │   ├── Faq.module.scss
        │   ├── Link.js
        │   ├── MiniApp.js
        │   ├── MiniApp.module.scss
        │   ├── Navbar.js
        │   ├── Navbar.module.scss
        │   ├── Repo.js
        │   ├── Repo.module.scss
        │   ├── Section.js
        │   ├── Section.module.scss
        │   └── index.js
        ├── images/
        │   └── index.js
        ├── index.js
        ├── reportWebVitals.js
        ├── service-worker.js
        ├── serviceWorkerRegistration.js
        ├── style.scss
        └── twemojis/
            ├── Emojis.js
            └── Emojis.module.scss
Download .txt
SYMBOL INDEX (186 symbols across 46 files)

FILE: unfollow-monkey-ui/src/App.js
  function App (line 39) | function App() {

FILE: unfollow-monkey-ui/src/components/Faq.js
  function Faq (line 6) | function Faq(props) {

FILE: unfollow-monkey-ui/src/components/MiniApp.js
  constant API_URL (line 22) | const API_URL = 'https://api.unfollow-monkey.com';
  function formatMessage (line 56) | function formatMessage(message) {
  function MiniApp (line 106) | function MiniApp(props) {

FILE: unfollow-monkey-ui/src/components/MiniApp/LanguageSelector.js
  function LanguageSelector (line 11) | function LanguageSelector(props) {

FILE: unfollow-monkey-ui/src/components/MiniApp/ProCard.js
  function ProCard (line 9) | function ProCard(props) {

FILE: unfollow-monkey-ui/src/components/Repo.js
  function Repo (line 5) | function Repo(props) {

FILE: unfollow-monkey-ui/src/images/index.js
  function useAlaska (line 16) | function useAlaska() { // react hook to get the right alaska image url

FILE: unfollow-monkey-ui/src/index.js
  constant DSN (line 7) | const DSN = process.env.REACT_APP_SENTRY_DSN;

FILE: unfollow-monkey-ui/src/serviceWorkerRegistration.js
  function register (line 21) | function register(config) {
  function registerValidSW (line 55) | function registerValidSW(swUrl, config) {
  function checkValidServiceWorker (line 99) | function checkValidServiceWorker(swUrl, config) {
  function unregister (line 127) | function unregister() {

FILE: unfollow-ninja-server/src/api.ts
  function assertEnvVariable (line 20) | function assertEnvVariable(name: string) {
  constant SENTRY_DSN (line 32) | const SENTRY_DSN = process.env.SENTRY_DSN_API || undefined;
  type NinjaSession (line 57) | interface NinjaSession {

FILE: unfollow-ninja-server/src/api/admin.ts
  function createAdminRouter (line 10) | function createAdminRouter(dao: Dao, queue: Queue) {

FILE: unfollow-ninja-server/src/api/auth.ts
  constant DEFAULT_LANGUAGE (line 42) | const DEFAULT_LANGUAGE = (process.env.DEFAULT_LANGUAGE || 'en') as Lang;
  function createAuthRouter (line 44) | function createAuthRouter(dao: Dao, queue: Queue) {

FILE: unfollow-ninja-server/src/api/stripe.ts
  type Price (line 178) | interface Price {
  constant PRICES (line 186) | const PRICES: Record<string, Price> = {

FILE: unfollow-ninja-server/src/api/user.ts
  function createUserRouter (line 12) | function createUserRouter(dao: Dao, queue: Queue) {

FILE: unfollow-ninja-server/src/dao/dao.ts
  type UserCategory (line 10) | enum UserCategory {
  type ICachedUsername (line 20) | interface ICachedUsername extends Model {
  type IFriendCode (line 24) | interface IFriendCode extends Model {
  class Dao (line 30) | class Dao {
    method constructor (line 40) | constructor(
    method load (line 94) | public async load(): Promise<Dao> {
    method disconnect (line 108) | public async disconnect() {
    method getUserDao (line 113) | public getUserDao(userId: string) {
    method addUser (line 117) | public async addUser(userEgg: IUserEgg): Promise<void> {
    method getUserIds (line 150) | public async getUserIds(): Promise<string[]> {
    method getUserIdsByCategory (line 154) | public async getUserIdsByCategory(category: UserCategory): Promise<str...
    method getUserCountByCategory (line 158) | public async getUserCountByCategory(): Promise<Record<UserCategory, nu...
    method getCachedUsername (line 166) | public async getCachedUsername(userId: string): Promise<string> {
    method getCachedUserId (line 170) | public async getCachedUserId(username: string): Promise<string> {
    method addTwittoToCache (line 176) | public async addTwittoToCache(twittoInfo: ITwittoInfo): Promise<void> {
    method getSession (line 184) | public async getSession(uid: string): Promise<Session> {
    method setSession (line 188) | public async setSession(uid: string, params: Record<string, string>): ...
    method deleteSession (line 193) | public async deleteSession(uid: string): Promise<void> {
    method getTokenSecret (line 197) | public async getTokenSecret(token: string): Promise<string> {
    method setTokenSecret (line 201) | public async setTokenSecret(token: string, secret: string): Promise<vo...

FILE: unfollow-ninja-server/src/dao/userDao.ts
  type ITemporaryFollowerList (line 13) | interface ITemporaryFollowerList
  type IFollowersDetail (line 20) | interface IFollowersDetail extends Model<InferAttributes<IFollowersDetai...
  class UserDao (line 28) | class UserDao {
    method constructor (line 36) | constructor(userId: string, dao: Dao) {
    method createTables (line 69) | public async createTables() {
    method getUsername (line 74) | public getUsername(): Promise<string> {
    method getCategory (line 78) | public async getCategory(): Promise<UserCategory> {
    method setCategory (line 82) | public async setCategory(category: UserCategory): Promise<void> {
    method enable (line 89) | public async enable(): Promise<UserCategory.enabled | UserCategory.vip> {
    method getNextCheckTime (line 104) | public async getNextCheckTime(): Promise<number> {
    method setNextCheckTime (line 109) | public async setNextCheckTime(nextCheckTime: number | string): Promise...
    method getTemporaryFollowerList (line 114) | public async getTemporaryFollowerList(): Promise<{ nextCursor: string;...
    method setTemporaryFollowerList (line 122) | public async setTemporaryFollowerList(nextCursor: string, followers: s...
    method deleteTemporaryFollowerList (line 130) | public async deleteTemporaryFollowerList(): Promise<void> {
    method getUserParams (line 134) | public async getUserParams(): Promise<IUserParams> {
    method setUserParams (line 144) | public async setUserParams(userParams: Partial<IUserParams>): Promise<...
    method getTwit (line 148) | public async getTwit(): Promise<Twit> {
    method getTwitterApi (line 161) | public async getTwitterApi(): Promise<TwitterApi> {
    method getDmTwit (line 174) | public async getDmTwit(): Promise<Twit> {
    method getDmTwitterApi (line 187) | public async getDmTwitterApi(): Promise<TwitterApi> {
    method getLang (line 200) | public async getLang(): Promise<Lang> {
    method isPro (line 204) | public async isPro(): Promise<boolean> {
    method getDmId (line 208) | public getDmId(): Promise<string> {
    method getFollowers (line 214) | public async getFollowers(): Promise<string[]> {
    method updateFollowers (line 218) | public async updateFollowers(
    method setFollowerSnowflakeId (line 254) | public async setFollowerSnowflakeId(followerId: string, snowflakeId: s...
    method getFollowerSnowflakeId (line 263) | public async getFollowerSnowflakeId(followerId: string): Promise<strin...
    method getUncachableFollowers (line 275) | public async getUncachableFollowers(): Promise<string[]> {
    method addUncachableFollower (line 284) | public async addUncachableFollower(followerId: string): Promise<void> {
    method getFollowTime (line 293) | public async getFollowTime(followerId: string): Promise<number> {
    method getFollowDetectedTime (line 300) | public async getFollowDetectedTime(followerId: string): Promise<number...
    method getHasNotCachedFollowers (line 312) | public async getHasNotCachedFollowers(): Promise<boolean> {
    method getCachedFollowers (line 325) | public async getCachedFollowers(): Promise<string[]> {
    method getFriendCodes (line 355) | public async getFriendCodes(): Promise<IFriendCode[]> {
    method getFriendCodesWithUsername (line 361) | public async getFriendCodesWithUsername(): Promise<{ code: string; fri...
    method addFriendCodes (line 371) | public async addFriendCodes(): Promise<void> {
    method deleteFriendCodes (line 382) | public async deleteFriendCodes(code: string): Promise<void> {
    method registerFriendCode (line 386) | public async registerFriendCode(code: string): Promise<boolean> {
    method getRegisteredFriendCode (line 394) | public async getRegisteredFriendCode(): Promise<IFriendCode> {
    method getAllUserData (line 400) | public async getAllUserData() {
    method cleanUser (line 441) | public async cleanUser(): Promise<void> {
    method deleteUser (line 451) | public async deleteUser(): Promise<void> {

FILE: unfollow-ninja-server/src/dao/userEventDao.ts
  type WebEvent (line 11) | enum WebEvent {
  type IWebEvent (line 28) | interface IWebEvent extends Model<InferAttributes<IWebEvent>, InferCreat...
  type FollowEvent (line 36) | enum FollowEvent {
  type IFollowEvent (line 42) | interface IFollowEvent extends Model<InferAttributes<IFollowEvent>, Infe...
  type IUnfollowerEvent (line 49) | interface IUnfollowerEvent extends Model<InferAttributes<IUnfollowerEven...
  type NotificationEvent (line 65) | enum NotificationEvent {
  type INotificationEvent (line 70) | interface INotificationEvent
  type ICategoryEvent (line 79) | interface ICategoryEvent extends Model<InferAttributes<ICategoryEvent>, ...
  class UserEventDao (line 85) | class UserEventDao {
    method constructor (line 94) | constructor(dao: Dao) {
    method createTables (line 183) | public async createTables() {
    method logWebEvent (line 191) | public async logWebEvent(userId: string, event: WebEvent, ip: string, ...
    method getWebEvents (line 198) | public async getWebEvents(userId: string, limit = 500, offset = 0) {
    method logFollowEvent (line 212) | public async logFollowEvent(userId: string, event: FollowEvent, follow...
    method getFollowEvent (line 219) | public async getFollowEvent(userId: string, limit = 500, offset = 0) {
    method logUnfollowerEvent (line 233) | public async logUnfollowerEvent(userId: string, isSecondCheck: boolean...
    method getUnfollowerEvents (line 269) | public async getUnfollowerEvents(userId: string, limit = 500, offset =...
    method logNotificationEvent (line 278) | public async logNotificationEvent(userId: string, event: NotificationE...
    method getNotificationEvents (line 285) | public async getNotificationEvents(userId: string, limit = 500, offset...
    method logCategoryEvent (line 299) | public async logCategoryEvent(userId: string, category: UserCategory, ...
    method getCategoryEvents (line 306) | public async getCategoryEvents(userId: string, limit = 500, offset = 0) {

FILE: unfollow-ninja-server/src/jobs/cleanUsersWithRevokedTokens.ts
  function runJob (line 5) | async function runJob() {

FILE: unfollow-ninja-server/src/jobs/deleteRedisSnowflakeIds.ts
  function run (line 8) | async function run() {

FILE: unfollow-ninja-server/src/jobs/emptyQueue.ts
  function runJob (line 19) | async function runJob() {

FILE: unfollow-ninja-server/src/jobs/migrateCachedUsernamesFromRedisToPostres.ts
  function run (line 8) | async function run() {

FILE: unfollow-ninja-server/src/jobs/migrateFollowersFromRedisToPostres.ts
  type IFollowersDetail (line 9) | interface IFollowersDetail extends Model<InferAttributes<IFollowersDetai...
  function run (line 18) | async function run() {

FILE: unfollow-ninja-server/src/jobs/resetCachedSnowflakeIds.ts
  function run (line 5) | async function run() {

FILE: unfollow-ninja-server/src/jobs/setUsersLanguage.ts
  function runJob (line 5) | async function runJob() {

FILE: unfollow-ninja-server/src/jobs/twitExperiment.ts
  function run (line 6) | async function run() {

FILE: unfollow-ninja-server/src/tasks/notifyUser.ts
  constant MINUTES_BETWEEN_CHECKS (line 20) | const MINUTES_BETWEEN_CHECKS = Number(process.env.MINUTES_BETWEEN_CHECKS...
  constant TWITTER_ACCOUNT (line 21) | const TWITTER_ACCOUNT = process.env.TWITTER_ACCOUNT || 'unfollowninja';
  constant MAX_UNFOLLOWERS (line 25) | const MAX_UNFOLLOWERS = 25;
  method run (line 28) | public async run(job: Job) {
  method generateMessage (line 194) | private generateMessage(unfollowersInfo: IUnfollowerInfo[], lang: Lang, ...
  method manageTwitterErrors (line 286) | private async manageTwitterErrors(err: unknown, username: string, userId...

FILE: unfollow-ninja-server/src/tasks/reenableFollowers.ts
  constant CATEGORIES_TO_CHECK (line 12) | const CATEGORIES_TO_CHECK = [
  method run (line 20) | public run(job: Job) {
  method checkAccountValid (line 39) | private async checkAccountValid(userId: string) {

FILE: unfollow-ninja-server/src/tasks/sendWelcomeMessage.ts
  constant TWITTER_ACCOUNT (line 15) | const TWITTER_ACCOUNT = process.env.TWITTER_ACCOUNT || 'unfollowninja';
  method run (line 18) | public async run(job: Job) {
  method manageTwitterErrors (line 58) | private async manageTwitterErrors(err: unknown, username: string, userId...

FILE: unfollow-ninja-server/src/tasks/task.ts
  method constructor (line 8) | constructor(dao: Dao, queue: Queue) {
  type TaskClass (line 16) | type TaskClass = new (dao: Dao, queue: Queue) => Task;

FILE: unfollow-ninja-server/src/tasks/updateMetrics.ts
  method run (line 6) | public async run() {

FILE: unfollow-ninja-server/src/utils/metrics.ts
  constant STATSD_HOST (line 3) | const STATSD_HOST = process.env.STATSD_HOST || undefined;
  constant DD_AGENT_HOST (line 4) | const DD_AGENT_HOST = process.env.DD_AGENT_HOST || undefined;
  constant METRICS_PREFIX (line 5) | const METRICS_PREFIX = process.env.METRICS_PREFIX || 'uninja';
  class Metrics (line 7) | class Metrics {
    method addStatsdHost (line 10) | public static addStatsdHost(host: string, port = 8125) {
    method gauge (line 14) | public static gauge(metric: string, value: number) {
    method increment (line 18) | public static increment(metric: string, value = 1) {
    method kill (line 22) | public static kill() {

FILE: unfollow-ninja-server/src/utils/types.ts
  type Lang (line 4) | type Lang = typeof SUPPORTED_LANGUAGES_CONST[number];
  type IFollowerInfo (line 6) | interface IFollowerInfo {
  type IUnfollowerInfo (line 11) | interface IUnfollowerInfo extends IFollowerInfo {
  type ITwittoInfo (line 27) | interface ITwittoInfo {
  type IUserParams (line 32) | interface IUserParams {
  type IUserEgg (line 45) | interface IUserEgg extends ITwittoInfo, IUserParams {
  type Session (line 49) | interface Session {

FILE: unfollow-ninja-server/src/utils/utils.ts
  function twitterCursorToTime (line 7) | function twitterCursorToTime(cursor: string): number {
  constant SUPPORTED_LANGUAGES_CONST (line 11) | const SUPPORTED_LANGUAGES_CONST = [
  constant SUPPORTED_LANGUAGES (line 28) | const SUPPORTED_LANGUAGES = SUPPORTED_LANGUAGES_CONST as unknown as stri...

FILE: unfollow-ninja-server/src/workers.ts
  constant CLUSTER_SIZE (line 16) | const CLUSTER_SIZE = Number(process.env.CLUSTER_SIZE) || cpus().length;
  constant WORKER_RATE_LIMIT (line 17) | const WORKER_RATE_LIMIT = Number(process.env.WORKER_RATE_LIMIT) || 15;
  constant SENTRY_DSN (line 18) | const SENTRY_DSN = process.env.SENTRY_DSN || undefined;
  function death (line 105) | async function death() {

FILE: unfollow-ninja-server/src/workers/cacheAllFollowers.ts
  constant CACHE_WORKER_RATE_LIMIT (line 9) | const CACHE_WORKER_RATE_LIMIT =
  function cacheAllFollowers (line 18) | async function cacheAllFollowers(workerId: number, nbWorkers: number, da...
  function hashCode (line 60) | function hashCode(s: string) {
  function cacheFollowers (line 68) | async function cacheFollowers(userId: string, dao: Dao) {

FILE: unfollow-ninja-server/src/workers/checkAllFollowers.ts
  constant WORKER_RATE_LIMIT (line 13) | const WORKER_RATE_LIMIT = Number(process.env.WORKER_RATE_LIMIT) || 15;
  constant VIP_WORKER_RATE_LIMIT (line 14) | const VIP_WORKER_RATE_LIMIT = Number(process.env.VIP_WORKER_RATE_LIMIT) ...
  function checkAllFollowers (line 23) | async function checkAllFollowers(workerId: number, nbWorkers: number, da...
  function checkAllVipFollowers (line 47) | async function checkAllVipFollowers(workerId: number, nbWorkers: number,...
  function hashCode (line 72) | function hashCode(s: string) {
  function checkFollowersWithTimeout (line 86) | async function checkFollowersWithTimeout(userId: string, dao: Dao, queue...
  function checkFollowers (line 111) | async function checkFollowers(userId: string, dao: Dao, queue: Queue) {
  function detectUnfollows (line 195) | async function detectUnfollows(userId: string, followers: string[], dao:...
  function manageTwitterErrors (line 263) | async function manageTwitterErrors(twitterReply: Twit.Twitter.Errors, us...

FILE: unfollow-ninja-server/tests/dao/userDao.spec.ts
  constant USER_PARAMS_1 (line 29) | const USER_PARAMS_1: IUserParams = {
  constant USER_PARAMS_2 (line 35) | const USER_PARAMS_2: IUserParams = {

FILE: unfollow-ninja-server/tests/tasks/notifyUser.spec.ts
  function mockUsersLookupReply (line 21) | function mockUsersLookupReply(ids: string[], screenNames: string[], frie...
  function mockFriendshipShowReply (line 31) | function mockFriendshipShowReply(
  function mockFriendshipShowReplyNotFound (line 57) | function mockFriendshipShowReplyNotFound() {

FILE: unfollow-ninja-server/tests/utils.ts
  function userDaoMock (line 5) | function userDaoMock() {
  function _daoMock (line 33) | function _daoMock() {
  function daoMock (line 53) | function daoMock() {
  function queueMock (line 57) | function queueMock() {
  type DmSendParams (line 63) | interface DmSendParams {
  function twitMock (line 67) | function twitMock() {

FILE: unfollow-ninja-ui/src/App.js
  function App (line 49) | function App() {

FILE: unfollow-ninja-ui/src/components/Faq.js
  function Faq (line 6) | function Faq(props) {

FILE: unfollow-ninja-ui/src/components/MiniApp.js
  function MiniApp (line 10) | function MiniApp(props) {

FILE: unfollow-ninja-ui/src/components/Repo.js
  function Repo (line 5) | function Repo(props) {

FILE: unfollow-ninja-ui/src/images/index.js
  function useAlaska (line 12) | function useAlaska() { // react hook to get the right alaska image url

FILE: unfollow-ninja-ui/src/index.js
  constant DSN (line 7) | const DSN = process.env.REACT_APP_SENTRY_DSN;

FILE: unfollow-ninja-ui/src/serviceWorkerRegistration.js
  function register (line 21) | function register(config) {
  function registerValidSW (line 55) | function registerValidSW(swUrl, config) {
  function checkValidServiceWorker (line 99) | function checkValidServiceWorker(swUrl, config) {
  function unregister (line 127) | function unregister() {
Condensed preview — 137 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (361K chars).
[
  {
    "path": ".github/workflows/server-ci.yml",
    "chars": 1418,
    "preview": "name: Server CI\non: push\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        node-version: [1"
  },
  {
    "path": ".gitignore",
    "chars": 1059,
    "preview": "# Based on https://github.com/github/gitignore/blob/master/Node.gitignore\n#\n# Logs\n*.log\n*.log.gz\nnpm-debug.log*\nyarn-de"
  },
  {
    "path": "license.md",
    "chars": 582,
    "preview": "Copyright 2019 Paul-Louis Hery https://twitter.com/plhery\n\nLicensed under the Apache License, Version 2.0 (the \"License\""
  },
  {
    "path": "readme.md",
    "chars": 5863,
    "preview": "# Unfollow Ninja [![Server CI status](https://github.com/PLhery/unfollowNinja/workflows/Server%20CI/badge.svg)](https://"
  },
  {
    "path": "unfollow-monkey-ui/.gitignore",
    "chars": 310,
    "preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
  },
  {
    "path": "unfollow-monkey-ui/package.json",
    "chars": 931,
    "preview": "{\n  \"name\": \"unfollow-ninja-ui\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"@sentry/browser\": \"^7"
  },
  {
    "path": "unfollow-monkey-ui/public/_redirects",
    "chars": 37,
    "preview": "/1 /index.html 200\n/2 /index.html 200"
  },
  {
    "path": "unfollow-monkey-ui/public/favicon/site.webmanifest",
    "chars": 456,
    "preview": "{\n    \"name\": \"Unfollow Monkey\",\n    \"short_name\": \"Unfollow Monkey\",\n    \"icons\": [\n        {\n            \"src\": \"/andr"
  },
  {
    "path": "unfollow-monkey-ui/public/index.html",
    "chars": 1947,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <link rel=\"shortcut icon\" href=\"%PUBLIC_URL%/"
  },
  {
    "path": "unfollow-monkey-ui/public/manifest.json",
    "chars": 304,
    "preview": "{\n  \"short_name\": \"Unfollow Monkey\",\n  \"name\": \"Unfollow Monkey\",\n  \"icons\": [\n    {\n      \"src\": \"favicon.ico\",\n      \""
  },
  {
    "path": "unfollow-monkey-ui/public/privacy-policy.txt",
    "chars": 10162,
    "preview": "PRIVACY POLICY - UNFOLLOW MONKEY - https://unfollow-monkey.com\n---------------------------------------------------------"
  },
  {
    "path": "unfollow-monkey-ui/public/robots.txt",
    "chars": 62,
    "preview": "User-agent: *\nSitemap: https://unfollow-monkey.com/sitemap.xml"
  },
  {
    "path": "unfollow-monkey-ui/public/sitemap.xml",
    "chars": 178,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n    <url>\n        <l"
  },
  {
    "path": "unfollow-monkey-ui/public/tos.txt",
    "chars": 34074,
    "preview": "TERMS OF SERVICE - UNFOLLOW MONKEY - https://unfollow-monkey.com\n-------------------------------------------------------"
  },
  {
    "path": "unfollow-monkey-ui/src/App.js",
    "chars": 4529,
    "preview": "import React from 'react';\n\nimport './style.scss';\n\nimport {Box, Grommet, Heading, Image, Paragraph, Text} from 'grommet"
  },
  {
    "path": "unfollow-monkey-ui/src/components/Faq.js",
    "chars": 3589,
    "preview": "import React from 'react';\nimport { Box, Heading, Paragraph } from \"grommet/es6\";\nimport Emojis from '../twemojis/Emojis"
  },
  {
    "path": "unfollow-monkey-ui/src/components/Faq.module.scss",
    "chars": 188,
    "preview": ".container {\n  background-color: rgba(255, 255, 255, 0.5);\n  p, ul {\n    text-align: justify;\n  }\n  h3 {\n    margin-bott"
  },
  {
    "path": "unfollow-monkey-ui/src/components/Link.js",
    "chars": 216,
    "preview": "import React from 'react';\n\nconst Link = (props) => (\n    <a target='_blank' rel='noopener noreferrer' style={{color: 'i"
  },
  {
    "path": "unfollow-monkey-ui/src/components/MiniApp/LanguageSelector.js",
    "chars": 1606,
    "preview": "import React from 'react';\nimport {Paragraph, Select} from \"grommet\";\n\nimport Styles from './LanguageSelector.module.scs"
  },
  {
    "path": "unfollow-monkey-ui/src/components/MiniApp/LanguageSelector.module.scss",
    "chars": 410,
    "preview": ".languageSelector {\n  padding-left: 10px;\n}\n\ndiv[role=menubar] button:disabled {\n  opacity: 0.8;\n\n  a {\n    svg {\n      "
  },
  {
    "path": "unfollow-monkey-ui/src/components/MiniApp/ProCard.js",
    "chars": 3012,
    "preview": "import React, {useState} from 'react';\nimport {Card, Paragraph, TextInput} from \"grommet\";\n\nimport Styles from './ProCar"
  },
  {
    "path": "unfollow-monkey-ui/src/components/MiniApp/ProCard.module.scss",
    "chars": 551,
    "preview": ".pro {\n  color: #b742a0;\n}\n\n.googlePayIcon {\n  height: 26px;\n  margin-right: 5px;\n}\n\n.checkoutCard {\n  display: flex;\n  "
  },
  {
    "path": "unfollow-monkey-ui/src/components/MiniApp.js",
    "chars": 8683,
    "preview": "import React, {useEffect, useState} from 'react';\nimport {\n\tAccordion,\n\tAccordionPanel,\n\tBox,\n\tButton,\n\tParagraph,\n\tSpin"
  },
  {
    "path": "unfollow-monkey-ui/src/components/MiniApp.module.scss",
    "chars": 116,
    "preview": ".centerIcon {\n  vertical-align: sub;\n}\n\n.confettis {\n  margin: 0 auto;\n}\n\n.loggedInDetails {\n  text-align: center;\n}"
  },
  {
    "path": "unfollow-monkey-ui/src/components/Navbar.js",
    "chars": 916,
    "preview": "import React from 'react';\nimport {Box, Heading, Image} from \"grommet/es6\";\nimport * as Images from \"../images\";\nimport "
  },
  {
    "path": "unfollow-monkey-ui/src/components/Navbar.module.scss",
    "chars": 712,
    "preview": ".navbar {\n  a {\n    text-decoration: none;\n  }\n\n  transition: margin .2s ease;\n\n  .logoContainer {\n    overflow: hidden;"
  },
  {
    "path": "unfollow-monkey-ui/src/components/Repo.js",
    "chars": 2493,
    "preview": "import React from 'react';\nimport Styles from './Repo.module.scss';\nimport Link from \"./Link\";\n\nfunction Repo(props) {\n "
  },
  {
    "path": "unfollow-monkey-ui/src/components/Repo.module.scss",
    "chars": 781,
    "preview": ".card {\n  font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI E"
  },
  {
    "path": "unfollow-monkey-ui/src/components/Section.js",
    "chars": 318,
    "preview": "import React from 'react';\nimport {Box} from \"grommet/es6\";\nimport Styles from './Section.module.scss';\n\nconst Section ="
  },
  {
    "path": "unfollow-monkey-ui/src/components/Section.module.scss",
    "chars": 174,
    "preview": ".sloped {\n  padding-top:80px!important;\n  clip-path: polygon(\n                  0 0,\n                  100% 80px,\n      "
  },
  {
    "path": "unfollow-monkey-ui/src/components/index.js",
    "chars": 253,
    "preview": "export {default as Faq} from './Faq';\nexport {default as Link} from './Link';\nexport {default as MiniApp} from './MiniAp"
  },
  {
    "path": "unfollow-monkey-ui/src/images/flags/index.js",
    "chars": 609,
    "preview": "export {default as fr} from './fr.svg';\nexport {default as en} from './en.svg';\nexport {default as es} from './es.svg';\n"
  },
  {
    "path": "unfollow-monkey-ui/src/images/index.js",
    "chars": 1287,
    "preview": "import { useState, useEffect } from 'react';\nimport Alaska from './alaska.jpg';\nimport AlaskaWebp from './alaska.webp';\n"
  },
  {
    "path": "unfollow-monkey-ui/src/index.js",
    "chars": 1013,
    "preview": "import React from 'react';\nimport { hydrate, render } from \"react-dom\";\nimport App from './App';\nimport * as Sentry from"
  },
  {
    "path": "unfollow-monkey-ui/src/reportWebVitals.js",
    "chars": 364,
    "preview": "const reportWebVitals = (onPerfEntry) => {\n  if (onPerfEntry && onPerfEntry instanceof Function) {\n    import('web-vital"
  },
  {
    "path": "unfollow-monkey-ui/src/service-worker.js",
    "chars": 2915,
    "preview": "/* eslint-disable no-restricted-globals */\n\n// This service worker can be customized!\n// See https://developers.google.c"
  },
  {
    "path": "unfollow-monkey-ui/src/serviceWorkerRegistration.js",
    "chars": 5064,
    "preview": "// This optional code is used to register a service worker.\n// register() is not called by default.\n\n// This lets the ap"
  },
  {
    "path": "unfollow-monkey-ui/src/style.scss",
    "chars": 73,
    "preview": "body {\n  margin: 0;\n  color: #4c4e6e;\n}\nimg {\n  vertical-align: middle;\n}"
  },
  {
    "path": "unfollow-monkey-ui/src/twemojis/Emojis.js",
    "chars": 795,
    "preview": "import React from 'react';\nimport Styles from './Emojis.module.scss';\n\nimport ImgWavingHand from './1f44b.png';\nimport I"
  },
  {
    "path": "unfollow-monkey-ui/src/twemojis/Emojis.module.scss",
    "chars": 94,
    "preview": "img.emoji {\n  height: 1em;\n  width: 1em;\n  margin: 0 .05em 0 .1em;\n  vertical-align: -0.1em;\n}"
  },
  {
    "path": "unfollow-ninja-server/.dockerignore",
    "chars": 27,
    "preview": "node_modules\nnpm-debug.log\n"
  },
  {
    "path": "unfollow-ninja-server/.eslintrc.json",
    "chars": 660,
    "preview": "{\n    \"env\": {\n        \"browser\": false,\n        \"commonjs\": true,\n        \"es2021\": true,\n        \"jest/globals\": true\n"
  },
  {
    "path": "unfollow-ninja-server/.prettierignore",
    "chars": 17,
    "preview": "dist\nnode_modules"
  },
  {
    "path": "unfollow-ninja-server/.prettierrc.json",
    "chars": 70,
    "preview": "{\n    \"singleQuote\": true,\n    \"tabWidth\": 4,\n    \"printWidth\": 120\n}\n"
  },
  {
    "path": "unfollow-ninja-server/Dockerfile",
    "chars": 342,
    "preview": "# -- For workers only, this does not launch the API\nFROM node:18\n\n# Create app directory\nWORKDIR /usr/src/app\n\n# Install"
  },
  {
    "path": "unfollow-ninja-server/docker-compose.yml",
    "chars": 2454,
    "preview": "version: '3'\n\nservices:\n    workers:\n        restart: always\n        build: .\n        depends_on:\n            - postgres"
  },
  {
    "path": "unfollow-ninja-server/jest.config.js",
    "chars": 535,
    "preview": "module.exports = {\n    clearMocks: true,\n    collectCoverage: false,\n    collectCoverageFrom: ['src/tasks/*.ts'],\n    co"
  },
  {
    "path": "unfollow-ninja-server/locales/ar.json",
    "chars": 1372,
    "preview": "{\n    \"This account followed you for {{duration}} ({{{time}}}).\": \"هذا الحساب قام بمتابعتك منذ {{duration}} ({{{time}}})"
  },
  {
    "path": "unfollow-ninja-server/locales/de.json",
    "chars": 1332,
    "preview": "{\n    \"{{username}} has been suspended {{emoji}}.\": \"{{username}} ist gesperrt worden {{emoji}}.\",\n    \"{{username}} has"
  },
  {
    "path": "unfollow-ninja-server/locales/en.json",
    "chars": 1407,
    "preview": "{\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"{{nbUnfollows}} Twitter users unfollowed you:\",\n    \"one of your"
  },
  {
    "path": "unfollow-ninja-server/locales/es.json",
    "chars": 1427,
    "preview": "{\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"{{nbUnfollows}} usuarios de twitter te han dejado de seguir:\",\n "
  },
  {
    "path": "unfollow-ninja-server/locales/fr.json",
    "chars": 1446,
    "preview": "{\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"{{nbUnfollows}} twittos vous ont unfollow :\",\n    \"one of your f"
  },
  {
    "path": "unfollow-ninja-server/locales/hy.json",
    "chars": 3,
    "preview": "{}\n"
  },
  {
    "path": "unfollow-ninja-server/locales/id.json",
    "chars": 1479,
    "preview": "{\n    \"This account followed you before you signed up to @{{twitterAccount}}!\": \"Akun ini sudah mengikuti kamu sebelum k"
  },
  {
    "path": "unfollow-ninja-server/locales/nl.json",
    "chars": 1443,
    "preview": "{\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"{{nbUnfollows}} Twittergebruikers die je ontvolgden:\",\n    \"one "
  },
  {
    "path": "unfollow-ninja-server/locales/pl.json",
    "chars": 1492,
    "preview": "{\n    \"{{username}} has been suspended {{emoji}}.\": \"{{username}} został zawieszony {{emoji}}.\",\n    \"{{username}} has l"
  },
  {
    "path": "unfollow-ninja-server/locales/pt.json",
    "chars": 1461,
    "preview": "{\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"{{nbUnfollows}} utilizadores do twitter deixaram de te seguir:\","
  },
  {
    "path": "unfollow-ninja-server/locales/pt_BR.json",
    "chars": 1441,
    "preview": "{\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"{{nbUnfollows}} Usuários do Twitter deixaram de te seguir:\",\n   "
  },
  {
    "path": "unfollow-ninja-server/locales/ru.json",
    "chars": 1462,
    "preview": "{\n    \"one of your followers\": \"один из ваших подписчиков\",\n    \"{{username}} blocked you {{emoji}}.\": \"{{username}} заб"
  },
  {
    "path": "unfollow-ninja-server/locales/sk.json",
    "chars": 757,
    "preview": "{\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"{{nbUnfollows}} Twitter užívatelia/užívateľov ťa prestalo sledov"
  },
  {
    "path": "unfollow-ninja-server/locales/th.json",
    "chars": 1312,
    "preview": "{\n    \"{{username}} has been suspended {{emoji}}.\": \"{{username}} ได้ถูกระงับการใช้งาน {{emoji}}.\",\n    \"{{username}} bl"
  },
  {
    "path": "unfollow-ninja-server/locales/tr.json",
    "chars": 1494,
    "preview": "{\n    \"one of your followers\": \"takipçilerinden biri\",\n    \"{{username}} unfollowed you {{emoji}}.\": \"{{username}} seni "
  },
  {
    "path": "unfollow-ninja-server/locales/uk.json",
    "chars": 1435,
    "preview": "{\n    \"one of your followers\": \"один з ваших підписників\",\n    \"{{username}} unfollowed you {{emoji}}.\": \"{{username}} в"
  },
  {
    "path": "unfollow-ninja-server/locales/zgh.json",
    "chars": 1363,
    "preview": "{\n    \"{{nbUnfollows}} Twitter users unfollowed you:\": \"ⴽⴽⵙⵏ {{nbUnfollows}} ⵉⵏⵙⵙⵎⵔⴰⵙ ⵏ ⵜⵡⵉⵜⵔ ⵜⵉⴹⴼⵕⵉ ⵏⵏⴽ:\",\n    \"one of "
  },
  {
    "path": "unfollow-ninja-server/locales/zh_Hans.json",
    "chars": 1174,
    "preview": "{\n    \"one of your followers\": \"你的其中的一个关注者\",\n    \"{{username}} unfollowed you {{emoji}}.\": \"{{username}} 取消关注你 {{emoji}}"
  },
  {
    "path": "unfollow-ninja-server/package.json",
    "chars": 2646,
    "preview": "{\n    \"name\": \"unfollowninja\",\n    \"version\": \"2.0.0\",\n    \"description\": \"Receive a direct message in a few seconds whe"
  },
  {
    "path": "unfollow-ninja-server/pm2.yml",
    "chars": 184,
    "preview": "apps:\n    - script: ./dist/workers.js\n      name: ninja-workers\n      kill_timeout: 10000\n    - script: ./dist/api.js\n  "
  },
  {
    "path": "unfollow-ninja-server/src/api/admin.ts",
    "chars": 5132,
    "preview": "import Router from 'koa-router';\nimport type { Queue } from 'bull';\n\nimport type Dao from '../dao/dao';\nimport type { Ni"
  },
  {
    "path": "unfollow-ninja-server/src/api/auth.ts",
    "chars": 11019,
    "preview": "import TwitterApi from 'twitter-api-v2';\nimport Router from 'koa-router';\nimport type { Queue } from 'bull';\nimport geoi"
  },
  {
    "path": "unfollow-ninja-server/src/api/stripe.ts",
    "chars": 7786,
    "preview": "import type { Queue } from 'bull';\nimport type { ParameterizedContext } from 'koa';\nimport Stripe from 'stripe';\nimport "
  },
  {
    "path": "unfollow-ninja-server/src/api/user.ts",
    "chars": 5935,
    "preview": "import Router from 'koa-router';\nimport type { Queue } from 'bull';\nimport geoip from 'geoip-country';\n\nimport type Dao "
  },
  {
    "path": "unfollow-ninja-server/src/api.ts",
    "chars": 5187,
    "preview": "import 'dotenv/config';\n\nimport * as Sentry from '@sentry/node';\nimport '@sentry/tracing';\nimport Koa from 'koa';\nimport"
  },
  {
    "path": "unfollow-ninja-server/src/dao/dao.ts",
    "chars": 7197,
    "preview": "import Redis from 'ioredis';\nimport { DataTypes, Model, Sequelize } from 'sequelize';\nimport type { ModelStatic } from '"
  },
  {
    "path": "unfollow-ninja-server/src/dao/userDao.ts",
    "chars": 17938,
    "preview": "import Redis from 'ioredis';\nimport Twit from 'twit';\nimport { TwitterApi } from 'twitter-api-v2';\nimport crypto from 'c"
  },
  {
    "path": "unfollow-ninja-server/src/dao/userEventDao.ts",
    "chars": 11127,
    "preview": "import type { InferAttributes, InferCreationAttributes, Sequelize } from 'sequelize';\nimport { DataTypes, Model } from '"
  },
  {
    "path": "unfollow-ninja-server/src/jobs/cleanUsersWithRevokedTokens.ts",
    "chars": 696,
    "preview": "import Dao, { UserCategory } from '../dao/dao';\nimport logger from '../utils/logger';\n\n// Backup and delete for Redis us"
  },
  {
    "path": "unfollow-ninja-server/src/jobs/deleteRedisSnowflakeIds.ts",
    "chars": 1113,
    "preview": "import Redis from 'ioredis';\n\nimport Dao from '../dao/dao';\nimport logger from '../utils/logger';\nimport pLimit from 'p-"
  },
  {
    "path": "unfollow-ninja-server/src/jobs/emptyQueue.ts",
    "chars": 823,
    "preview": "import logger from '../utils/logger';\nimport Bull from 'bull';\nimport * as Sentry from '@sentry/node';\n\nconst bullQueue "
  },
  {
    "path": "unfollow-ninja-server/src/jobs/migrateCachedUsernamesFromRedisToPostres.ts",
    "chars": 1574,
    "preview": "import Redis from 'ioredis';\nimport { DataTypes, Sequelize } from 'sequelize';\n\nimport Dao from '../dao/dao';\nimport log"
  },
  {
    "path": "unfollow-ninja-server/src/jobs/migrateFollowersFromRedisToPostres.ts",
    "chars": 2854,
    "preview": "import Redis from 'ioredis';\nimport { DataTypes, InferAttributes, InferCreationAttributes, Model } from 'sequelize';\n\nim"
  },
  {
    "path": "unfollow-ninja-server/src/jobs/resetCachedSnowflakeIds.ts",
    "chars": 517,
    "preview": "import Redis from 'ioredis';\nimport Dao from '../dao/dao';\n\n// ran once (while it was in beta) to fix an inconsistency i"
  },
  {
    "path": "unfollow-ninja-server/src/jobs/setUsersLanguage.ts",
    "chars": 778,
    "preview": "import Dao from '../dao/dao';\nimport logger from '../utils/logger';\n\n// Following a bug, some unfollowMonkey users have "
  },
  {
    "path": "unfollow-ninja-server/src/jobs/twitExperiment.ts",
    "chars": 399,
    "preview": "import 'dotenv/config';\n\nimport Dao from '../dao/dao';\nimport logger from '../utils/logger';\n\nasync function run() {\n   "
  },
  {
    "path": "unfollow-ninja-server/src/tasks/index.ts",
    "chars": 393,
    "preview": "import notifyUser from './notifyUser';\nimport reenableFollowers from './reenableFollowers';\nimport sendWelcomeMessage fr"
  },
  {
    "path": "unfollow-ninja-server/src/tasks/notifyUser.ts",
    "chars": 14112,
    "preview": "import i18n from 'i18n';\nimport type { Job } from 'bull';\nimport moment from 'moment-timezone';\nimport { Params, Twitter"
  },
  {
    "path": "unfollow-ninja-server/src/tasks/reenableFollowers.ts",
    "chars": 1919,
    "preview": "import * as Sentry from '@sentry/node';\nimport type { Job } from 'bull';\n\nimport { UserCategory } from '../dao/dao';\nimp"
  },
  {
    "path": "unfollow-ninja-server/src/tasks/sendWelcomeMessage.ts",
    "chars": 3917,
    "preview": "import * as i18n from 'i18n';\nimport type { Job } from 'bull';\nimport { Params, Twitter } from 'twit';\nimport { UserCate"
  },
  {
    "path": "unfollow-ninja-server/src/tasks/task.ts",
    "chars": 385,
    "preview": "import type { Job, Queue } from 'bull';\nimport Dao from '../dao/dao';\n\nexport default abstract class Task {\n    protecte"
  },
  {
    "path": "unfollow-ninja-server/src/tasks/updateMetrics.ts",
    "chars": 682,
    "preview": "import { UserCategory } from '../dao/dao';\nimport Task from './task';\nimport metrics from '../utils/metrics';\n\nexport de"
  },
  {
    "path": "unfollow-ninja-server/src/utils/logger.ts",
    "chars": 1959,
    "preview": "import cluster from 'cluster';\nimport * as fs from 'fs';\nimport { createLogger, format, transports } from 'winston';\n\nle"
  },
  {
    "path": "unfollow-ninja-server/src/utils/metrics.ts",
    "chars": 1054,
    "preview": "import { StatsD } from 'hot-shots';\n\nconst STATSD_HOST = process.env.STATSD_HOST || undefined;\nconst DD_AGENT_HOST = pro"
  },
  {
    "path": "unfollow-ninja-server/src/utils/types.ts",
    "chars": 1254,
    "preview": "import type { UserCategory } from '../dao/dao';\nimport { SUPPORTED_LANGUAGES_CONST } from './utils';\n\nexport type Lang ="
  },
  {
    "path": "unfollow-ninja-server/src/utils/utils.ts",
    "chars": 619,
    "preview": "// Convert an ID generated with snowflake (e.g some cursors) to a timestamp in ms\nimport bigInt from 'big-integer';\n\n/**"
  },
  {
    "path": "unfollow-ninja-server/src/workers/cacheAllFollowers.ts",
    "chars": 6085,
    "preview": "import pLimit from 'p-limit';\nimport * as Sentry from '@sentry/node';\nimport { Twitter } from 'twit';\n\nimport Dao, { Use"
  },
  {
    "path": "unfollow-ninja-server/src/workers/checkAllFollowers.ts",
    "chars": 11758,
    "preview": "import type { Queue } from 'bull';\nimport pLimit from 'p-limit';\nimport * as Sentry from '@sentry/node';\nimport * as Twi"
  },
  {
    "path": "unfollow-ninja-server/src/workers.ts",
    "chars": 4616,
    "preview": "import 'dotenv/config';\n\nimport * as Sentry from '@sentry/node';\nimport '@sentry/tracing';\nimport cluster from 'cluster'"
  },
  {
    "path": "unfollow-ninja-server/tests/dao/__snapshots__/userDao.spec.ts.snap",
    "chars": 791,
    "preview": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`Test userDao should get a stable getAllUserData 1`] = `\n{\n  \"catego"
  },
  {
    "path": "unfollow-ninja-server/tests/dao/dao.spec.ts",
    "chars": 3694,
    "preview": "import Redis from 'ioredis';\nimport { Sequelize } from 'sequelize';\nimport RedisMock from 'ioredis-mock'; // @types/iore"
  },
  {
    "path": "unfollow-ninja-server/tests/dao/userDao.spec.ts",
    "chars": 11061,
    "preview": "import Redis from 'ioredis';\nimport { Sequelize } from 'sequelize';\nimport RedisMock from 'ioredis-mock';\n\nimport Dao, {"
  },
  {
    "path": "unfollow-ninja-server/tests/dao/userEventDao.spec.ts",
    "chars": 6170,
    "preview": "import Redis from 'ioredis';\nimport { Sequelize } from 'sequelize';\nimport RedisMock from 'ioredis-mock';\n\nimport Dao, {"
  },
  {
    "path": "unfollow-ninja-server/tests/docker-compose.yml",
    "chars": 1161,
    "preview": "version: '3'\n\nservices:\n    tests:\n        build: ..\n        command: 'npm run specs'\n        volumes:\n            - ..:"
  },
  {
    "path": "unfollow-ninja-server/tests/tasks/cacheFollowers.spec.ts.disabled",
    "chars": 4845,
    "preview": "// disabled as there will be experimental changes to come\nimport { Job } from 'kue';\nimport CacheFollowers from '../../s"
  },
  {
    "path": "unfollow-ninja-server/tests/tasks/checkFollowers.spec.ts.disabled",
    "chars": 6825,
    "preview": "// disabled as there will be experimental changes to come\nimport { Job } from 'kue';\nimport CheckFollowers from '../../s"
  },
  {
    "path": "unfollow-ninja-server/tests/tasks/notifyUser.spec.ts",
    "chars": 10597,
    "preview": "import type { Job } from 'bull';\nimport NotifyUser from '../../src/tasks/notifyUser';\nimport { IUnfollowerInfo } from '."
  },
  {
    "path": "unfollow-ninja-server/tests/tasks/sendWelcomeMessage.spec.ts",
    "chars": 1658,
    "preview": "import type { Job } from 'bull';\nimport SendWelcomeMessage from '../../src/tasks/sendWelcomeMessage';\nimport { daoMock, "
  },
  {
    "path": "unfollow-ninja-server/tests/utils.ts",
    "chars": 2042,
    "preview": "import type { Queue } from 'bull';\nimport type Dao from '../src/dao/dao';\nimport type Twit from 'twit';\n\nexport function"
  },
  {
    "path": "unfollow-ninja-server/tsconfig-build.json",
    "chars": 71,
    "preview": "{\n    \"extends\": \"./tsconfig.json\",\n    \"exclude\": [\"tests/**/*.ts\"]\n}\n"
  },
  {
    "path": "unfollow-ninja-server/tsconfig.json",
    "chars": 229,
    "preview": "{\n    \"include\": [\"src/**/*.ts\", \"tests/**/*.ts\"],\n    \"compilerOptions\": {\n        \"module\": \"commonjs\",\n        \"esMod"
  },
  {
    "path": "unfollow-ninja-ui/.gitignore",
    "chars": 310,
    "preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
  },
  {
    "path": "unfollow-ninja-ui/package.json",
    "chars": 931,
    "preview": "{\n  \"name\": \"unfollow-ninja-ui\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"@sentry/browser\": \"^7"
  },
  {
    "path": "unfollow-ninja-ui/public/_redirects",
    "chars": 37,
    "preview": "/1 /index.html 200\n/2 /index.html 200"
  },
  {
    "path": "unfollow-ninja-ui/public/favicon/site.webmanifest",
    "chars": 470,
    "preview": "{\n    \"name\": \"Unfollow Ninja\",\n    \"short_name\": \"Unfollow Ninja\",\n    \"icons\": [\n        {\n            \"src\": \"/favico"
  },
  {
    "path": "unfollow-ninja-ui/public/index.html",
    "chars": 2006,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <link rel=\"shortcut icon\" href=\"%PUBLIC_URL%/"
  },
  {
    "path": "unfollow-ninja-ui/public/manifest.json",
    "chars": 302,
    "preview": "{\n  \"short_name\": \"Unfollow Ninja\",\n  \"name\": \"Unfollow Ninja\",\n  \"icons\": [\n    {\n      \"src\": \"favicon.ico\",\n      \"si"
  },
  {
    "path": "unfollow-ninja-ui/public/robots.txt",
    "chars": 76,
    "preview": "User-agent: *\nDisallow: /cgu.pdf\nSitemap: https://unfollow.ninja/sitemap.xml"
  },
  {
    "path": "unfollow-ninja-ui/public/sitemap.xml",
    "chars": 173,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n    <url>\n        <l"
  },
  {
    "path": "unfollow-ninja-ui/src/App.js",
    "chars": 4686,
    "preview": "import React from 'react';\n\nimport './style.scss';\n\nimport {Box, Grommet, Heading, Image, Paragraph, Text} from 'grommet"
  },
  {
    "path": "unfollow-ninja-ui/src/components/Faq.js",
    "chars": 4144,
    "preview": "import React from 'react';\nimport { Box, Heading, Paragraph } from \"grommet/es6\";\nimport Emojis from '../twemojis/Emojis"
  },
  {
    "path": "unfollow-ninja-ui/src/components/Faq.module.scss",
    "chars": 188,
    "preview": ".container {\n  background-color: rgba(255, 255, 255, 0.5);\n  p, ul {\n    text-align: justify;\n  }\n  h3 {\n    margin-bott"
  },
  {
    "path": "unfollow-ninja-ui/src/components/Link.js",
    "chars": 216,
    "preview": "import React from 'react';\n\nconst Link = (props) => (\n    <a target='_blank' rel='noopener noreferrer' style={{color: 'i"
  },
  {
    "path": "unfollow-ninja-ui/src/components/MiniApp.js",
    "chars": 894,
    "preview": "import React from 'react';\nimport {\n    Box, Paragraph,\n} from 'grommet';\nimport {Alert} from \"grommet-icons\";\n\nimport '"
  },
  {
    "path": "unfollow-ninja-ui/src/components/MiniApp.module.scss",
    "chars": 178,
    "preview": ".centerIcon {\n  vertical-align: sub;\n}\n\n.confettis {\n  margin: 0 auto;\n}\n\n.loggedInDetails {\n  text-align: center;\n}\n\n.s"
  },
  {
    "path": "unfollow-ninja-ui/src/components/Navbar.js",
    "chars": 730,
    "preview": "import React from 'react';\nimport {Box, Heading, Image} from \"grommet/es6\";\nimport * as Images from \"../images\";\nimport "
  },
  {
    "path": "unfollow-ninja-ui/src/components/Navbar.module.scss",
    "chars": 241,
    "preview": ".navbar {\n  a {\n    text-decoration: none;\n  }\n\n  transition: margin .2s ease;\n  img {\n    transition: transform .4s eas"
  },
  {
    "path": "unfollow-ninja-ui/src/components/Repo.js",
    "chars": 2493,
    "preview": "import React from 'react';\nimport Styles from './Repo.module.scss';\nimport Link from \"./Link\";\n\nfunction Repo(props) {\n "
  },
  {
    "path": "unfollow-ninja-ui/src/components/Repo.module.scss",
    "chars": 781,
    "preview": ".card {\n  font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI E"
  },
  {
    "path": "unfollow-ninja-ui/src/components/Section.js",
    "chars": 318,
    "preview": "import React from 'react';\nimport {Box} from \"grommet/es6\";\nimport Styles from './Section.module.scss';\n\nconst Section ="
  },
  {
    "path": "unfollow-ninja-ui/src/components/Section.module.scss",
    "chars": 174,
    "preview": ".sloped {\n  padding-top:80px!important;\n  clip-path: polygon(\n                  0 0,\n                  100% 80px,\n      "
  },
  {
    "path": "unfollow-ninja-ui/src/components/index.js",
    "chars": 253,
    "preview": "export {default as Faq} from './Faq';\nexport {default as Link} from './Link';\nexport {default as MiniApp} from './MiniAp"
  },
  {
    "path": "unfollow-ninja-ui/src/images/index.js",
    "chars": 1052,
    "preview": "import { useState, useEffect } from 'react';\nimport Alaska from './alaska.jpg';\nimport AlaskaWebp from './alaska.webp';\n"
  },
  {
    "path": "unfollow-ninja-ui/src/index.js",
    "chars": 1015,
    "preview": "import React from 'react';\nimport { hydrate, render } from \"react-dom\";\nimport App from './App';\nimport * as Sentry from"
  },
  {
    "path": "unfollow-ninja-ui/src/reportWebVitals.js",
    "chars": 364,
    "preview": "const reportWebVitals = (onPerfEntry) => {\n  if (onPerfEntry && onPerfEntry instanceof Function) {\n    import('web-vital"
  },
  {
    "path": "unfollow-ninja-ui/src/service-worker.js",
    "chars": 2915,
    "preview": "/* eslint-disable no-restricted-globals */\n\n// This service worker can be customized!\n// See https://developers.google.c"
  },
  {
    "path": "unfollow-ninja-ui/src/serviceWorkerRegistration.js",
    "chars": 5064,
    "preview": "// This optional code is used to register a service worker.\n// register() is not called by default.\n\n// This lets the ap"
  },
  {
    "path": "unfollow-ninja-ui/src/style.scss",
    "chars": 73,
    "preview": "body {\n  margin: 0;\n  color: #4c4e6e;\n}\nimg {\n  vertical-align: middle;\n}"
  },
  {
    "path": "unfollow-ninja-ui/src/twemojis/Emojis.js",
    "chars": 795,
    "preview": "import React from 'react';\nimport Styles from './Emojis.module.scss';\n\nimport ImgWavingHand from './1f44b.png';\nimport I"
  },
  {
    "path": "unfollow-ninja-ui/src/twemojis/Emojis.module.scss",
    "chars": 94,
    "preview": "img.emoji {\n  height: 1em;\n  width: 1em;\n  margin: 0 .05em 0 .1em;\n  vertical-align: -0.1em;\n}"
  }
]

About this extraction

This page contains the full source code of the PLhery/unfollowNinja GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 137 files (329.4 KB), approximately 85.2k tokens, and a symbol index with 186 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!