diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..da76e2dc08 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,26 @@ +# See https://editorconfig.org + +root = true + +[*] +end_of_line = lf +charset = utf-8 +indent_size = 4 +indent_style = space +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{py,pyi}] +max_line_length = 88 + +[*.{md,yaml,yml}] +indent_size = 2 + +[Makefile] +indent_style = tab + +[*.diff] +trim_trailing_whitespace = false + +[.git/*] +trim_trailing_whitespace = false diff --git a/.github/ISSUE_TEMPLATE/01_crash.md b/.github/ISSUE_TEMPLATE/01_crash.md new file mode 100644 index 0000000000..7c11abcd4f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/01_crash.md @@ -0,0 +1,39 @@ +--- +name: Bug report (major) +about: A major bug which significantly impacts usability, eg. an unexpected quit, crash, freeze. +labels: 'bug: crash,' +--- + +### Bug description + + + + + + +### How is the bug triggered? +How can you reproduce the bug? +1. + + +### Does it produce a 'traceback' or 'exception'? + +``` + + +``` + +### How are you running the application? +Please include as many of the following as possible: +- **Zulip-terminal version:** + eg. a specific version (0.7.0), or if running from `main` also ideally the git ref +- **Zulip server version(s):** + eg. Zulip Cloud, the version you are running self-hosted, or the Zulip Community server (chat.zulip.org) +- **Operating system (and version):** + eg. Debian Linux, Ubuntu Linux, macOS, WSL in Windows, Docker +- **Python version (and implementation):** + eg. 3.8, 3.9, 3.10, ... (implementation is likely to be eg. CPython, or PyPy) + +If possible, please provide details from the `About` menu: (hotkey: Meta + ?) +(this can provide some of the details above) + diff --git a/.github/ISSUE_TEMPLATE/02_bug.md b/.github/ISSUE_TEMPLATE/02_bug.md new file mode 100644 index 0000000000..1ac0381330 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/02_bug.md @@ -0,0 +1,36 @@ +--- +name: Bug report +about: A concrete bug report with steps to reproduce the behavior. +labels: 'bug,' +--- + +### Bug description + + + + + + +### How is the bug triggered? +How can you reproduce the bug? +1. + + +### What did you expect to happen? + + + +### How are you running the application? +Please include as many of the following as possible: +- **Zulip-terminal version:** + eg. a specific version (0.7.0), or if running from `main` also ideally the git ref +- **Zulip server version(s):** + eg. Zulip Cloud, the version you are running self-hosted, or the Zulip Community server (chat.zulip.org) +- **Operating system (and version):** + eg. Debian Linux, Ubuntu Linux, macOS, WSL in Windows, Docker +- **Python version (and implementation):** + eg. 3.8, 3.9, 3.10, ... (implementation is likely to be eg. CPython, or PyPy) + +If possible, please provide details from the `About` menu: (hotkey: Meta + ?) +(this can provide some of the details above) + diff --git a/.github/ISSUE_TEMPLATE/03_parity.md b/.github/ISSUE_TEMPLATE/03_parity.md new file mode 100644 index 0000000000..a3018c516b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/03_parity.md @@ -0,0 +1,25 @@ +--- +name: Feature request (a missing known Zulip feature) +about: A request for a feature which is missing, but present in another Zulip client (eg. Web/Desktop/mobile). +labels: 'missing feature,' +--- + +### Description of feature missing from another Zulip client + + + + + + + +### When was this feature first available in Zulip? +If you know: +- **Zulip version:** + (eg. 2.1, 5.0, 8.0, ..., or 'Zulip Cloud today') +- **Zulip feature level:** + (see https://zulip.com/api/changelog) + + +### Other details + + diff --git a/.github/ISSUE_TEMPLATE/04_platform.md b/.github/ISSUE_TEMPLATE/04_platform.md new file mode 100644 index 0000000000..cd4592735a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/04_platform.md @@ -0,0 +1,19 @@ +--- +name: Feature suggestion +about: A suggestion for an improvement, specific to the capabilities of the terminal environment. +labels: 'enhancement,' +--- + + + +### Description of suggested feature + + + + + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..77fa18bed5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Report broader Zulip issues (beyond Zulip-Terminal) + about: Includes links to best-practices for filing bug reports, feature requests, security vulnerabilities and more. + url: https://github.com/zulip/zulip/issues/new/choose + - name: Not sure? + about: 'Chat with us, using any of the Zulip clients, in #zulip-terminal on the Developer Community server (chat.zulip.org).' + url: https://zulip.com/development-community diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c210fb441e..b06d94f49a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,36 +1,42 @@ - - -**What does this PR do?** - - - - - -**Tested?** -- [ ] Manually -- [ ] Existing tests (adapted, if necessary) -- [ ] New tests added (for any new behavior) -- [ ] Passed linting & tests (each commit) - - - -**Commit flow** - +### What does this PR do, and why? + + + +### Outstanding aspect(s) + + +- [ ] + +### External discussion & connections + +- [ ] Discussed in **#zulip-terminal** in `topic` +- [ ] Fully fixes # +- [ ] Partially fixes issue # +- [ ] Builds upon previous unmerged work in PR # +- [ ] Is a follow-up to work in PR # +- [ ] Requires merge of PR # +- [ ] Merge will enable work on # + +### How did you test this? + +- [ ] Manually - Behavioral changes +- [ ] Manually - Visual changes +- [ ] Adapting existing automated tests +- [ ] Adding automated tests for new behavior (or missing tests) +- [ ] Existing automated tests should already cover this (*only a refactor of tested code*) + +### Self-review checklist for each commit +- [ ] It is a [minimal coherent idea](https://github.com/zulip/zulip-terminal#structuring-commits---speeding-up-reviews-merging--development) +- [ ] It has a commit summary following the [documented style](https://github.com/zulip/zulip-terminal#structuring-commits---speeding-up-reviews-merging--development) (title & body) +- [ ] It has a commit summary describing the motivation and reasoning for the change +- [ ] It individually passes linting and tests +- [ ] It contains test additions for any new behavior +- [ ] It flows clearly from a previous branch commit, and/or prepares for the next commit + +### Visual changes + + - -**Notes & Questions** - - -**Interactions** - - -**Visual changes** + diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index ce6d89f2f0..44c3d17276 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -14,39 +14,43 @@ concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true +permissions: + contents: read + jobs: analyse: name: Analyse + if: ${{!github.event.repository.private}} + permissions: + actions: read + contents: read + security-events: write runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.9' - name: Install dependencies run: | python -m pip install --upgrade pip pip3 install . - - # Set the `CODEQL-PYTHON` environment variable to the Python executable - # that includes the dependencies - echo "CODEQL_PYTHON=$(which python)" >> $GITHUB_ENV # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: python # Override the default behavior so that the action doesn't attempt # to auto-install Python dependencies setup-python-dependencies: false - + # Override language selection by uncommenting this and choosing your languages # with: # languages: go, javascript, csharp, python, cpp, java - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index f52d1af047..d4799c4def 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -13,21 +13,27 @@ concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true +permissions: + contents: read + +env: + LINTING_PYTHON_VERSION: 3.8 + jobs: mypy: runs-on: ubuntu-latest name: Lint - Type consistency (mypy) steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: persist-credentials: false - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: - python-version: 3.7 + python-version: ${{ env.LINTING_PYTHON_VERSION }} cache: 'pip' cache-dependency-path: 'setup.py' - - name: Install with type-checking tools - run: pip install .[typing] + - name: Install with type-checking tools, stubs & minimal test libraries + run: pip install .[typing,testing_minimal] - name: Run mypy run: ./tools/run-mypy @@ -35,12 +41,12 @@ jobs: runs-on: ubuntu-latest name: Lint - PEP8 & more (ruff) steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: persist-credentials: false - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: - python-version: 3.7 + python-version: ${{ env.LINTING_PYTHON_VERSION }} cache: 'pip' cache-dependency-path: 'setup.py' - name: Install with linting tools @@ -52,30 +58,30 @@ jobs: runs-on: ubuntu-latest name: Lint - Import order (isort) steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: persist-credentials: false - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: - python-version: 3.7 + python-version: ${{ env.LINTING_PYTHON_VERSION }} cache: 'pip' cache-dependency-path: 'setup.py' - - name: Install with linting tools + - name: Install with linting tools & minimal test libraries # NOTE: Install pytest so that isort recognizes it as a known library - run: pip install .[linting] && pip install pytest + run: pip install .[linting,minimal_testing] - name: Run isort run: ./tools/run-isort-check black: runs-on: ubuntu-latest - name: Lint - Code formatting (black) + name: Lint - Code format (black) steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: persist-credentials: false - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: - python-version: 3.7 + python-version: ${{ env.LINTING_PYTHON_VERSION }} cache: 'pip' cache-dependency-path: 'setup.py' - name: Install with linting tools @@ -85,14 +91,14 @@ jobs: spellcheck: runs-on: ubuntu-latest - name: Lint - Spellcheck + name: Lint - Spellcheck (codespell, typos) steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: persist-credentials: false - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: - python-version: 3.7 + python-version: ${{ env.LINTING_PYTHON_VERSION }} cache: 'pip' cache-dependency-path: 'setup.py' - name: Install with linting tools @@ -102,14 +108,14 @@ jobs: hotkeys: runs-on: ubuntu-latest - name: Lint - Hotkeys linting & docs sync check + name: Lint - Hotkeys & docs sync (lint-hotkeys) steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: persist-credentials: false - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: - python-version: 3.7 + python-version: ${{ env.LINTING_PYTHON_VERSION }} cache: 'pip' cache-dependency-path: 'setup.py' - name: Minimal install @@ -119,14 +125,14 @@ jobs: docstrings: runs-on: ubuntu-latest - name: Lint - Docstrings linting & docs sync check + name: Lint - Docstrings & docs sync (lint-docstring) steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: persist-credentials: false - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: - python-version: 3.7 + python-version: ${{ env.LINTING_PYTHON_VERSION }} cache: 'pip' cache-dependency-path: 'setup.py' - name: Minimal install @@ -134,15 +140,44 @@ jobs: - name: Run lint-docstring run: ./tools/lint-docstring + gitlint: + runs-on: ubuntu-latest + name: Lint - Commit text format (gitlint) + steps: + - name: 'PR commits +1' + if: github.event_name == 'pull_request' + run: echo "PR_FETCH_DEPTH=$(( ${{ github.event.pull_request.commits }} + 1 ))" >> "${GITHUB_ENV}" + - name: 'Checkout PR branch and all PR commits' + if: github.event_name == 'pull_request' + uses: actions/checkout@v4 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: ${{ env.PR_FETCH_DEPTH }} + - uses: actions/setup-python@v5 + if: github.event_name == 'pull_request' + with: + python-version: ${{ env.LINTING_PYTHON_VERSION }} + cache: 'pip' + cache-dependency-path: 'setup.py' + - name: Install with gitlint + if: github.event_name == 'pull_request' + run: pip install .[gitlint] + - name: Run gitlint + if: github.event_name == 'pull_request' + run: + git fetch https://github.com/zulip/zulip-terminal main; + gitlint --commits FETCH_HEAD..${{ github.event.pull_request.head.sha }} + base_pytest: runs-on: ubuntu-latest name: Install & test - CPython 3.7 (ubuntu), codecov steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: persist-credentials: false - name: Install Python version - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3.7 cache: 'pip' @@ -172,17 +207,18 @@ jobs: - hotkeys - docstrings - base_pytest + - gitlint steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 if: github.event_name == 'pull_request' with: persist-credentials: false ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 if: github.event_name == 'pull_request' with: - python-version: 3.7 + python-version: ${{ env.LINTING_PYTHON_VERSION }} cache: 'pip' cache-dependency-path: 'setup.py' - name: Run check-branch @@ -206,17 +242,19 @@ jobs: - {PYTHON: 'pypy-3.7', OS: ubuntu-latest, NAME: "PyPy 3.7 (ubuntu)", EXPECT: "Linux"} - {PYTHON: 'pypy-3.8', OS: ubuntu-latest, NAME: "PyPy 3.8 (ubuntu)", EXPECT: "Linux"} - {PYTHON: 'pypy-3.9', OS: ubuntu-latest, NAME: "PyPy 3.9 (ubuntu)", EXPECT: "Linux"} - - {PYTHON: '3.x', OS: macos-latest, NAME: "CPython 3.x [latest] (macos)", EXPECT: "MacOS"} + - {PYTHON: 'pypy-3.10', OS: ubuntu-latest, NAME: "PyPy 3.10 (ubuntu)", EXPECT: "Linux"} + - {PYTHON: '3.11', OS: macos-latest, NAME: "CPython 3.11 (macos)", EXPECT: "MacOS"} env: EXPECT: ${{ matrix.env.EXPECT }} + PYTHON: ${{ matrix.env.PYTHON }} runs-on: ${{ matrix.env.OS }} name: Install & test - ${{ matrix.env.NAME}} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: persist-credentials: false - name: Install Python version ${{ matrix.env.PYTHON }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.env.PYTHON }} cache: 'pip' @@ -230,6 +268,8 @@ jobs: run: sudo apt install libxml2-dev libxslt1-dev - name: Ensure regular package installs from checkout run: pip install . + - name: Check we detect the python environment correctly + run: python -c "from zulipterminal.platform_code import detected_python_short; import os; e, d = os.environ['PYTHON'], detected_python_short(); assert d == e, f'{d} != {e}'" - name: Check we detect the platform correctly run: python -c "from zulipterminal.platform_code import detected_platform; import os; e, d = os.environ['EXPECT'], detected_platform(); assert d == e, f'{d} != {e}'" - name: Install test dependencies diff --git a/.gitlint b/.gitlint index 1e2e15fa21..9fec2d8393 100644 --- a/.gitlint +++ b/.gitlint @@ -55,3 +55,6 @@ ignore=all # Ignore certain rules, you can reference them by their id or by their full name # Use 'all' to ignore all rules # ignore=T1,body-min-length + +[ignore-body-lines] +regex=^(Co-authored-by:| *https?://\S+$) diff --git a/.mailmap b/.mailmap index 5f2ab4ea61..d495ac82df 100644 --- a/.mailmap +++ b/.mailmap @@ -13,3 +13,8 @@ Sumanth V Rao Tim Abbott Zeeshan Equbal <54993043+zee-bit@users.noreply.github.com> Zeeshan Equbal +Mounil K. Shah +Mounil K. Shah +Vishwesh Pillai +Sashank Ravipati +Niloth P <20315308+Niloth-p@users.noreply.github.com> diff --git a/README.md b/README.md index f9722224fe..33d38e287e 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,11 @@ [Recent changes](https://github.com/zulip/zulip-terminal/blob/main/CHANGELOG.md) | [Configuration](#Configuration) | [Hot Keys](https://github.com/zulip/zulip-terminal/blob/main/docs/hotkeys.md) | [FAQs](https://github.com/zulip/zulip-terminal/blob/main/docs/FAQ.md) | [Development](#contributor-guidelines) | [Tutorial](https://github.com/zulip/zulip-terminal/blob/main/docs/getting-started.md) -[![Zulip chat](https://img.shields.io/badge/zulip-join_chat-brightgreen.svg)](https://chat.zulip.org/#narrow/stream/206-zulip-terminal) +[![Chat with us!](https://img.shields.io/badge/Zulip-chat_with_us!-brightgreen.svg)](https://github.com/zulip/zulip-terminal/blob/main/README.md#chat-with-fellow-users--developers) [![PyPI](https://img.shields.io/pypi/v/zulip-term.svg)](https://pypi.python.org/pypi/zulip-term) -[![Python Versions](https://img.shields.io/pypi/pyversions/zulip-term.svg)](https://pypi.python.org/pypi/zulip-term) +[![Python Versions](https://img.shields.io/pypi/pyversions/zulip-term.svg)](https://github.com/zulip/zulip-terminal/blob/main/docs/FAQ.md#what-python-versions-are-supported) +[![Python Implementations](https://img.shields.io/pypi/implementation/zulip-term.svg)](https://github.com/zulip/zulip-terminal/blob/main/docs/FAQ.md#what-python-implementations-are-supported) +[![OS Platforms](https://img.shields.io/static/v1?label=OS&message=Linux%20%7C%20WSL%20%7C%20macOS%20%7C%20Docker&color=blueviolet)](https://github.com/zulip/zulip-terminal/blob/main/docs/FAQ.md#what-operating-systems-are-supported) [![GitHub Actions - Linting & tests](https://github.com/zulip/zulip-terminal/workflows/Linting%20%26%20tests/badge.svg?branch=main)](https://github.com/zulip/zulip-terminal/actions?query=workflow%3A%22Linting+%26+tests%22+branch%3Amain) [![Coverage status](https://img.shields.io/codecov/c/github/zulip/zulip-terminal/main.svg)](https://app.codecov.io/gh/zulip/zulip-terminal/branch/main) @@ -38,6 +40,18 @@ Learn how to use Zulip Terminal with our We consider the client to already provide a fairly stable moderately-featureful everyday-user experience. +The current development focus is on improving aspects of everyday usage which +are more commonly used - to reduce the need for users to temporarily switch to +another client for a particular feature. + +Current limitations which we expect to only resolve over the long term include support for: +* All operations performed by users with extra privileges (owners/admins) +* Accessing and updating all settings +* Using a mouse/pointer to achieve all actions +* An internationalized UI + +#### Intentional differences + The terminal client currently has a number of intentional differences to the Zulip web client: - Additional and occasionally *different* [Hot keys](https://github.com/zulip/zulip-terminal/blob/main/docs/hotkeys.md) @@ -58,53 +72,13 @@ The terminal client currently has a number of intentional differences to the Zul - Content previewable in the web client, such as images, are also stored as footlinks -The current development focus is on improving aspects of everyday usage which -are more commonly used - to reduce the need for users to temporarily switch to -another client for a particular feature. - -Current limitations which we expect to only resolve over the long term include support for: -* All operations performed by users with extra privileges (owners/admins) -* Accessing and updating all settings -* Using a mouse/pointer to achieve all actions -* An internationalized UI +#### Feature queries? For queries on missing feature support please take a look at the [Frequently Asked Questions (FAQs)](https://github.com/zulip/zulip-terminal/blob/main/docs/FAQ.md), -our open [Issues](https://github.com/zulip/zulip-terminal/issues/), or sign up -on https://chat.zulip.org and chat with users and developers in the -[#zulip-terminal](https://chat.zulip.org/#narrow/stream/206-zulip-terminal) -stream! - -### Supported platforms -- Linux -- OSX -- WSL (On Windows) - -### Supported Server Versions - -The minimum server version that Zulip Terminal supports is -[`2.1.0`](https://zulip.readthedocs.io/en/latest/overview/changelog.html#zulip-2-1-x-series). -It may still work with earlier versions. - -### Supported Python Versions - -Version 0.7.0 was the last release with support for Python 3.6. - -Version 0.6.0 was the last release with support for Python 3.5. - -Later releases and the main development branch are currently tested (on Ubuntu) -with: -- CPython 3.7-3.11 -- PyPy 3.7-3.9 - -Since our automated testing does not cover interactive testing of the UI, there -may be issues with some Python versions, though generally we have not found -this to be the case. - -Please note that generally we limit each release to between a lower and upper -Python version, so it is possible that for example if you have a newer version -of Python installed, then some releases (or `main`) may not install correctly. -In some cases this can give rise to the symptoms in issue #1145. +our open [Issues](https://github.com/zulip/zulip-terminal/issues/), or +[chat with users & developers](#chat-with-fellow-users--developers) online at +the Zulip Community server! ## Installation @@ -133,7 +107,7 @@ systems: (creates a virtual environment named `zt_venv` in the current directory) 2. `source zt_venv/bin/activate` (activates the virtual environment; this assumes a bash-like shell) -3. Run one of the install commands above, +3. Run one of the install commands above. If you open a different terminal window (or log-off/restart your computer), you'll need to run **step 2** of the above list again before running @@ -233,6 +207,12 @@ to get the hang of things. ## Configuration +configuration conssist of two file: +- zulip_key, file contains the api_key +- zuliprc, file consist of login configurations + +The `zulip_key`contains only the api_key. + The `zuliprc` file contains two sections: - an `[api]` section with information required to connect to your Zulip server - a `[zterm]` section with configuration specific to `zulip-term` @@ -242,13 +222,15 @@ A file with only the first section can be auto-generated in some cases by above). Parts of the second section can be added and adjusted in stages when you wish to customize the behavior of `zulip-term`. +If you’re downloading the config file from your Zulip account, you should replace the `key` field with `passcmd`, setting its value to a command that outputs the api_key (e.g., cat zulip_key). If you’re not downloading it manually, zulip-term will configure this for you automatically, though it’s recommended to update the passcmd value afterward for better security. + The example below, with dummy `[api]` section contents, represents a working configuration file with all the default compatible `[zterm]` values uncommented and with accompanying notes: ``` [api] email=example@example.com -key=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +passcmd=cat zulip_key site=https://example.zulipchat.com [zterm] @@ -258,6 +240,9 @@ theme=zt_dark ## Autohide: set to 'autohide' to hide the left & right panels except when they're focused autohide=no_autohide +## Exit Confirmation: set to 'disabled' to exit directly with no warning popup first +exit_confirmation=enabled + ## Footlinks: set to 'disabled' to hide footlinks; 'enabled' will show the first 3 per message ## For more flexibility, comment-out this value, and un-comment maximum-footlinks below footlinks=enabled @@ -269,8 +254,18 @@ notify=disabled ## Color-depth: set to one of 1 (for monochrome), 16, 256, or 24bit color-depth=256 + +## Transparency: set to 'enabled' to allow background transparency +## This is highly dependent on a suitable terminal emulator, and support in the selected theme +## Terminal emulators without this feature may show an arbitrary solid background color +transparency=disabled + +## Editor: set external editor command, to edit message content +## If not set, this falls back to the $ZULIP_EDITOR_COMMAND then $EDITOR environment variables +# editor: nano ``` + > **NOTE:** Most of these configuration settings may be specified on the command line when `zulip-term` is started; `zulip-term -h` or `zulip-term --help` will give the full list of options. @@ -334,56 +329,143 @@ sudo apt-get install xsel No additional package is required to enable copying to clipboard. +## Chat with fellow users & developers! + +While Zulip Terminal is designed to work with any Zulip server, the main +contributors are present on the Zulip Community server at +https://chat.zulip.org, with most conversation in the +[**#zulip-terminal** stream](https://chat.zulip.org/#narrow/stream/206-zulip-terminal). + +You are welcome to view conversations in that stream using the link above, or +sign up for an account and chat with us - whether you are a user or developer! + +We aim to keep the Zulip community friendly, welcoming and productive, so if +participating, please respect our +[Community norms](https://zulip.com/development-community/#community-norms). + +### Notes more relevant to the **#zulip-terminal** stream + +These are a subset of the **Community norms** linked above, which are more +relevant to users of Zulip Terminal: those more likely to be in a text +environment, limited in character rows/columns, and present in this one smaller +stream. + +* **Prefer text in [code blocks](https://zulip.com/help/code-blocks), instead of screenshots** + + Zulip Terminal supports downloading images, but there is no guarantee that + users will be able to view them. + + *Try Meta+m to see example content formatting, including code blocks* + +* **Prefer [silent mentions](https://zulip.com/help/mention-a-user-or-group#silently-mention-a-user) + over regular mentions - or avoid mentions entirely** + + With Zulip's topics, the intended recipient can often already be clear. + Experienced members will be present as their time permits - responding + to messages when they return - and others may be able to assist before then. + + (Save [regular mentions](https://zulip.com/help/mention-a-user-or-group#mention-a-user-or-group_1) + for those who you do not expect to be present on a regular basis) + + *Try Ctrl+f/b to cycle through autocompletion in message content, after typing `@_` to specify a silent mention* + +* **Prefer trimming [quote and reply](https://zulip.com/help/quote-and-reply) + text to only the relevant parts of longer messages - or avoid quoting entirely** + + Zulip's topics often make it clear which message you're replying to. Long + messages can be more difficult to read with limited rows and columns of text, + but this is worsened if quoting an entire long message with extra content. + + *Try > to quote a selected message, deleting text as normal when composing a message* + +* **Prefer a [quick emoji reaction](https://zulip.com/help/emoji-reactions) +to show agreement instead of simple short messages** + + Reactions take up less space, including in Zulip Terminal, particularly + when multiple users wish to respond with the same sentiment. + + *Try + to toggle thumbs-up (+1) on a message, or use : to search for other reactions* + ## Contributor Guidelines Zulip Terminal is being built by the awesome [Zulip](https://zulip.com/team) community. -To be a part of it and to contribute to the code, feel free to work on any [issue](https://github.com/zulip/zulip-terminal/issues) or propose your idea on +To be a part of it and to contribute to the code, feel free to work on any +[issue](https://github.com/zulip/zulip-terminal/issues) or propose your idea on [#zulip-terminal](https://chat.zulip.org/#narrow/stream/206-zulip-terminal). -For commit structure and style, please review the [Commit Style](#commit-style) section below. +For commit structure and style, please review the [Commit Style](#commit-style) +section below. -If you are new to `git` (or not!), you may benefit from the [Zulip git guide](http://zulip.readthedocs.io/en/latest/git/index.html). -When contributing, it's important to note that we use a **rebase-oriented workflow**. +If you are new to `git` (or not!), you may benefit from the +[Zulip git guide](http://zulip.readthedocs.io/en/latest/git/index.html). +When contributing, it's important to note that we use a **rebase-oriented +workflow**. -A simple [tutorial](https://github.com/zulip/zulip-terminal/blob/main/docs/developer-feature-tutorial.md) is available for implementing the `typing` indicator. +A simple +[tutorial](https://github.com/zulip/zulip-terminal/blob/main/docs/developer-feature-tutorial.md) +is available for implementing the `typing` indicator. Follow it to understand how to implement a new feature for zulip-terminal. -You can of course browse the source on GitHub & in the source tree you download, and check the [source file overview](https://github.com/zulip/zulip-terminal/docs/developer-file-overview.md) for ideas of how files are currently arranged. +You can of course browse the source on GitHub & in the source tree you +download, and check the +[source file overview](https://github.com/zulip/zulip-terminal/blob/main/docs/developer-file-overview.md) +for ideas of how files are currently arranged. ### Urwid -Zulip Terminal uses [urwid](http://urwid.org/) to render the UI components in terminal. Urwid is an awesome library through which you can render a decent terminal UI just using python. [Urwid's Tutorial](http://urwid.org/tutorial/index.html) is a great place to start for new contributors. +Zulip Terminal uses [urwid](http://urwid.org/) to render the UI components in +terminal. +Urwid is an awesome library through which you can render a decent terminal UI +just using python. +[Urwid's Tutorial](http://urwid.org/tutorial/index.html) is a great place to +start for new contributors. ### Getting Zulip Terminal code and connecting it to upstream -First, fork the `zulip/zulip-terminal` repository on GitHub ([see how](https://docs.github.com/en/get-started/quickstart/fork-a-repo)) and then clone your forked repository locally, replacing **YOUR_USERNAME** with your GitHub username: +First, fork the `zulip/zulip-terminal` repository on GitHub +([see how](https://docs.github.com/en/get-started/quickstart/fork-a-repo)) +and then clone your forked repository locally, replacing **YOUR_USERNAME** with +your GitHub username: + ``` $ git clone --config pull.rebase git@github.com:YOUR_USERNAME/zulip-terminal.git ``` -This should create a new directory for the repository in the current directory, so enter the repository directory with `cd zulip-terminal` and configure and fetch the upstream remote repository for your cloned fork of Zulip Terminal: +This should create a new directory for the repository in the current directory, +so enter the repository directory with `cd zulip-terminal` and configure and +fetch the upstream remote repository for your cloned fork of Zulip Terminal: + ``` $ git remote add -f upstream https://github.com/zulip/zulip-terminal.git ``` -For detailed explanation on the commands used for cloning and setting upstream, refer to Step 1 of the [Get Zulip Code](https://zulip.readthedocs.io/en/latest/git/cloning.html) section of Zulip's Git guide. +For detailed explanation on the commands used for cloning and setting upstream, +refer to Step 1 of the +[Get Zulip Code](https://zulip.readthedocs.io/en/latest/git/cloning.html) +section of Zulip's Git guide. ### Setting up a development environment -Various options are available; we are exploring the benefits of each and would appreciate feedback on which you use or feel works best. +Various options are available; we are exploring the benefits of each and would +appreciate feedback on which you use or feel works best. -Note that the tools used in each case are typically the same, but are called in different ways. +Note that the tools used in each case are typically the same, but are called in +different ways. -The following commands should be run in the repository directory, created by a process similar to that in the previous section. +The following commands should be run in the repository directory, created by a +process similar to that in the previous section. #### Pipenv -1. Install pipenv (see the [recommended installation notes](https://pipenv.readthedocs.io/en/latest/install/#pragmatic-installation-of-pipenv); pipenv can be installed in a virtual environment, if you wish) +1. Install pipenv + (see the [recommended installation notes](https://pipenv.readthedocs.io/en/latest/installation); + pipenv can be installed in a virtual environment, if you wish) ``` $ pip3 install --user pipenv ``` -2. Initialize the pipenv virtual environment for zulip-term (using the default python 3; use eg. `--python 3.6` to be more specific) +2. Initialize the pipenv virtual environment for zulip-term (using the default + python 3; use eg. `--python 3.6` to be more specific) ``` $ pipenv --three @@ -396,9 +478,16 @@ $ pipenv install --dev $ pipenv run pip3 install -e '.[dev]' ``` +4. Connect the gitlint commit-message hook + +``` +$ pipenv run gitlint install-hook +``` + #### Pip -1. Manually create & activate a virtual environment; any method should work, such as that used in the above simple installation +1. Manually create & activate a virtual environment; any method should work, + such as that used in the above simple installation 1. `python3 -m venv zt_venv` (creates a venv named `zt_venv` in the current directory) 2. `source zt_venv/bin/activate` (activates the venv; this assumes a bash-like shell) @@ -408,12 +497,18 @@ $ pipenv run pip3 install -e '.[dev]' $ pip3 install -e '.[dev]' ``` +3. Connect the gitlint commit-message hook +``` +$ gitlint install-hook +``` + #### make/pip This is the newest and simplest approach, if you have `make` installed: 1. `make` (sets up an installed virtual environment in `zt_venv` in the current directory) 2. `source zt_venv/bin/activate` (activates the venv; this assumes a bash-like shell) +3. `gitlint install-hook` (connect the gitlint commit-message hook) ### Development tasks @@ -428,54 +523,72 @@ Once you have a development environment set up, you might find the following use | Run all tests | `pytest` | `pipenv run pytest` | | Build test coverage report | `pytest --cov-report html:cov_html --cov=./` | `pipenv run pytest --cov-report html:cov_html --cov=./` | -If using make with pip, running `make` will ensure the development environment is up to date with the specified dependencies, useful after fetching from git and rebasing. +If using make with pip, running `make` will ensure the development environment +is up to date with the specified dependencies, useful after fetching from git +and rebasing. -#### Passing linters and automated tests +### Editing the source -The linters and automated tests (pytest) are run in CI (GitHub Actions) when -you submit a pull request (PR), and we expect them to pass before code is -merged. -> **NOTE:** Mergeable PRs with multiple commits are expected to pass linting -> and tests at **each commit**, not simply overall +Pick your favorite text editor or development environment! -Running these tools locally can speed your development and avoid the need -to repeatedly push your code to GitHub simply to run these checks. -> If you have troubles understanding why the linters or pytest are failing, -> please do push your code to a branch/PR and we can discuss the problems in -> the PR or on chat.zulip.org. +The source includes an `.editorconfig` file which enables many editors to +automatically configure themselves to produce files which meet the minimum +requirements for the project. See https://editorconfig.org for editor support; +note that some may require plugins if you wish to use this feature. -All linters and tests can be run using the commands in the table above. -Individual linters may also be run via scripts in `tools/`. +### Running linters and automated tests + +The linters and automated tests (pytest) are automatically run in CI (GitHub +Actions) when you submit a pull request (PR), or push changes to an existing +pull request. + +However, running these checks on your computer can speed up your development by +avoiding the need to repeatedly push your code to GitHub. +Commands to achieve this are listed in the table of development tasks above +(individual linters may also be run via scripts in `tools/`). In addition, if using a `make`-based system: - `make lint` and `make test` run all of each group of tasks - `make check` runs all checks, which is useful before pushing a PR (or an update) +- `tools/check-branch` will run `make check` on each commit in your branch + +> **NOTE: It is highly unlikely that a pull request will be merged, until *all* +linters and tests are passing, including on a per-commit basis.** + +#### The linters and tests aren't passing on my branch/PR - what do I do? Correcting some linting errors requires manual intervention, such as from `mypy` for type-checking. + +For tips on testing, please review the section further below regarding pytest. + However, other linting errors may be fixed automatically, as detailed below - **this can save a lot of time manually adjusting your code to pass the linters!** -#### Updating hotkeys & file docstrings, vs related documentation +> If you have troubles understanding why the linters or pytest are failing, +> please do push your code to a branch/PR and we can discuss the problems in +> the PR or on chat.zulip.org. + +#### Automatically updating hotkeys & file docstrings, vs related documentation If you update these, note that you do not need to update the text in both places manually to pass linting. The source of truth is in the source code, so simply update the python file and -run the relevant tool, as detailed below. - -Currently we have +run the relevant tool. Currently we have: * `tools/lint-hotkeys --fix` to regenerate docs/hotkeys.md from config/keys.py * `tools/lint-docstring --fix` to regenerate docs/developer-file-overview.md from file docstrings -(these tools are also used for the linting process to ensure that these files are synchronzed) +(these tools are also used for the linting process, to ensure that these files +are synchronized) -#### Auto-formatting code +#### Automatically formatting code The project uses `black` and `isort` for code-style and import sorting respectively. -These tools can be run as linters locally , but can also *automatically* format your code for you. +These tools can be run as linters locally, but can also *automatically* format +your code for you. If you're using a `make`-based setup, running `make fix` will run both (and a few other tools) and reformat the current state of your code - so you'll want @@ -485,7 +598,7 @@ the changes. You can also use the tools individually on a file or directory, eg. `black zulipterminal` or `isort tests/model/test_model.py` -#### Structuring Commits - speeding up reviews, merging & development +### Structuring Commits - speeding up reviews, merging & development As you work locally, investigating changes to make, it's common to make a series of small commits to store your progress. @@ -558,9 +671,11 @@ respect reviewers' time. #### Commit Message Style -We aim to follow a standard commit style to keep the `git log` consistent and easy to read. +We aim to follow a standard commit style to keep the `git log` consistent and +easy to read. -Much like working with code, we suggest you refer to recent commits in the git log, for examples of the style we're actively using. +Much like working with code, we suggest you refer to recent commits in the git +log, for examples of the style we're actively using. Our overall style for commit messages broadly follows the general guidelines given for @@ -587,64 +702,123 @@ Some example commit titles: (ideally more descriptive in practice!) * `requirements: Upgrade some-dependency from ==9.2 to ==9.3.` - upgrade a dependency from version ==9.2 to version ==9.3, in the central dependencies file (*not* some file requirements.*) -To aid in satisfying some of these rules you can use `GitLint`, as described in the following section. +To aid in satisfying some of these rules you can use `GitLint`, as described in +the following section. -**However**, please check your commits manually versus these style rules, since GitLint cannot check everything - including language or grammar! +**However**, please check your commits manually versus these style rules, since +GitLint cannot check everything - including language or grammar! #### GitLint -If you plan to submit git commits in pull-requests (PRs), then we highly suggest installing the `gitlint` commit-message hook by running `gitlint install-hook` (or `pipenv run gitlint install-hook` with pipenv setups). While the content still depends upon your writing skills, this ensures a more consistent formatting structure between commits, including by different authors. - -If the hook is installed as described above, then after completing the text for a commit, it will be checked by gitlint against the style we have set up, and will offer advice if there are any issues it notices. If gitlint finds any, it will ask if you wish to commit with the message as it is (`y` for 'yes'), stop the commit process (`n` for 'no'), or edit the commit message (`e` for 'edit'). +The `gitlint` tool is installed by default in the development environment, and +can help ensure that your commits meet the expected standard. -Other gitlint options are available; for example it is possible to apply it to a range of commits with the `--commits` option, eg. `gitlint --commits HEAD~2..HEAD` would apply it to the last few commits. +The tool can check specific commits manually, eg. `gitlint` for the latest +commit, or `gitlint --commits main..` for commits leading from `main`. +However, we highly recommend running `gitlint install-hook` to install the +`gitlint` commit-message hook +(or `pipenv run gitlint install-hook` with pipenv setups). -### Tips for working with tests (pytest) +If the hook is installed as described above, then after completing the text for +a commit, it will be checked by gitlint against the style we have set up, and +will offer advice if there are any issues it notices. +If gitlint finds any, it will ask if you wish to commit with the message as it +is (`y` for 'yes'), stop the commit process (`n` for 'no'), or edit the commit +message (`e` for 'edit'). -Tests for zulip-terminal are written using [pytest](https://pytest.org/). You can read the tests in the `/tests` folder to learn about writing tests for a new class/function. If you are new to pytest, reading its documentation is definitely recommended. +While the content still depends upon your writing skills, this ensures a more +consistent formatting structure between commits, including by different +authors. -We currently have thousands of tests which get checked upon running `pytest`. While it is dependent on your system capability, this should typically take less than one minute to run. However, during debugging you may still wish to limit the scope of your tests, to improve the turnaround time: -* If lots of tests are failing in a very verbose way, you might try the `-x` option (eg. `pytest -x`) to stop tests after the first failure; due to parametrization of tests and test fixtures, many apparent errors/failures can be resolved with just one fix! (try eg. `pytest --maxfail 3` for a less-strict version of this) -* To avoid running all the successful tests each time, along with the failures, you can run with `--lf` (eg. `pytest --lf`), short for `--last-failed` (similar useful options may be `--failed-first` and `--new-first`, which may work well with `-x`) -* Since pytest 3.10 there is `--sw` (`--stepwise`), which works through known failures in the same way as `--lf` and `-x` can be used, which can be combined with `--stepwise-skip` to control which test is the current focus -* If you know the names of tests which are failing and/or in a specific location, you might limit tests to a particular location (eg. `pytest tests/model`) or use a selected keyword (eg. `pytest -k __handle`) - -When only a subset of tests are running it becomes more practical and useful to use the `-v` option (`--verbose`); instead of showing a `.` (or `F`, `E`, `x`, etc) for each test result, it gives the name (with parameters) of each test being run (eg. `pytest -v -k __handle`). This option also shows more detail in tests and can be given multiple times (eg. `pytest -vv`). +### Tips for working with tests (pytest) -For additional help with pytest options see `pytest -h`, or check out the [full pytest documentation](https://docs.pytest.org/en/latest/). +Tests for zulip-terminal are written using [pytest](https://pytest.org/). +You can read the tests in the `/tests` folder to learn about writing tests for +a new class/function. +If you are new to pytest, reading its documentation is definitely recommended. + +We currently have thousands of tests which get checked upon running `pytest`. +While it is dependent on your system capability, this should typically take +less than one minute to run. +However, during debugging you may still wish to limit the scope of your tests, +to improve the turnaround time: + +* If lots of tests are failing in a very verbose way, you might try the `-x` + option (eg. `pytest -x`) to stop tests after the first failure; due to +parametrization of tests and test fixtures, many apparent errors/failures can +be resolved with just one fix! (try eg. `pytest --maxfail 3` for a less-strict +version of this) + +* To avoid running all the successful tests each time, along with the failures, + you can run with `--lf` (eg. `pytest --lf`), short for `--last-failed` +(similar useful options may be `--failed-first` and `--new-first`, which may +work well with `-x`) + +* Since pytest 3.10 there is `--sw` (`--stepwise`), which works through known + failures in the same way as `--lf` and `-x` can be used, which can be +combined with `--stepwise-skip` to control which test is the current focus + +* If you know the names of tests which are failing and/or in a specific + location, you might limit tests to a particular location (eg. `pytest +tests/model`) or use a selected keyword (eg. `pytest -k __handle`) + +When only a subset of tests are running it becomes more practical and useful to +use the `-v` option (`--verbose`); instead of showing a `.` (or `F`, `E`, `x`, +etc) for each test result, it gives the name (with parameters) of each test +being run (eg. `pytest -v -k __handle`). +This option also shows more detail in tests and can be given multiple times +(eg. `pytest -vv`). + +For additional help with pytest options see `pytest -h`, or check out the [full +pytest documentation](https://docs.pytest.org/en/latest/). ### Debugging Tips #### Output using `print` -The stdout (standard output) for zulip-terminal is redirected to `./debug.log` if debugging is enabled at run-time using `-d` or `--debug`. +The stdout (standard output) for zulip-terminal is redirected to `./debug.log` +if debugging is enabled at run-time using `-d` or `--debug`. -This means that if you want to check the value of a variable, or perhaps indicate reaching a certain point in the code, you can simply use `print()`, eg. +This means that if you want to check the value of a variable, or perhaps +indicate reaching a certain point in the code, you can simply use `print()`, +eg. ```python3 print(f"Just about to do something with {variable}") ``` -and when running with a debugging option, the string will be printed to `./debug.log`. +and when running with a debugging option, the string will be printed to +`./debug.log`. -With a bash-like terminal, you can run something like `tail -f debug.log` in another terminal, to see the output from `print` as it happens. +With a bash-like terminal, you can run something like `tail -f debug.log` in +another terminal, to see the output from `print` as it happens. #### Interactive debugging using pudb & telnet -If you want to debug zulip-terminal while it is running, or in a specific state, you can insert +If you want to debug zulip-terminal while it is running, or in a specific +state, you can insert ```python3 from pudb.remote import set_trace set_trace() ``` -in the part of the code you want to debug. This will start a telnet connection for you. You can find the IP address and +in the part of the code you want to debug. +This will start a telnet connection for you. You can find the IP address and port of the telnet connection in `./debug.log`. Then simply run ``` $ telnet 127.0.0.1 6899 ``` -in another terminal, where `127.0.0.1` is the IP address and `6899` is port you find in `./debug.log`. +in another terminal, where `127.0.0.1` is the IP address and `6899` is port you +find in `./debug.log`. #### There's no effect in Zulip Terminal after making local changes! -This likely means that you have installed both normal and development versions of zulip-terminal. +This likely means that you have installed both normal and development versions +of zulip-terminal. To ensure you run the development version: -* If using pipenv, call `pipenv run zulip-term` from the cloned/downloaded `zulip-terminal` directory; -* If using pip (pip3), ensure you have activated the correct virtual environment (venv); depending on how your shell is configured, the name of the venv may appear in the command prompt. Note that not including the `-e` in the pip3 command will also cause this problem. +* If using pipenv, call `pipenv run zulip-term` from the cloned/downloaded + `zulip-terminal` directory; + +* If using pip (pip3), ensure you have activated the correct virtual + environment (venv); depending on how your shell is configured, the name of +the venv may appear in the command prompt. +Note that not including the `-e` in the pip3 command will also cause this +problem. diff --git a/docs/FAQ.md b/docs/FAQ.md index b141e0ae26..04a5d160d9 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -1,12 +1,98 @@ # Frequently Asked Questions (FAQ) -## Colors appear mismatched, don't change with theme, or look strange - -Some terminal emulators support specifying custom colors, or custom color schemes. If you do this then this can override the colors that Zulip Terminal attempts to use. - -**NOTE** If you have color issues, also note that urwid version 2.1.1 should have fixed these for various terminal emulators (including rxvt, urxvt, mosh and Terminal.app), so please ensure you are running the latest Zulip Terminal release and at least this urwid version before reporting issues. - -## It doesn't seem to run or display properly in my terminal (emulator)? +- Supported environments + - [What Python implementations are supported?](#what-python-implementations-are-supported) + - [What Python versions are supported?](#what-python-versions-are-supported) + - [What operating systems are supported?](#what-operating-systems-are-supported) + - [What versions of Zulip are supported?](#what-versions-of-zulip-are-supported) + - [Will it run and display properly in my terminal (emulator)?](#will-it-run-and-display-properly-in-my-terminal-emulator) + - [How small a size of terminal is supported?](#how-small-a-size-of-terminal-is-supported) +- Features + - [Are there any themes available, other than the default one?](#are-there-any-themes-available-other-than-the-default-one) + - [How do links in messages work? What are footlinks?](#how-do-links-in-messages-work-what-are-footlinks) + - [When are messages marked as having been read?](#when-are-messages-marked-as-having-been-read) + - [How do I access multiple servers?](#how-do-i-access-multiple-servers) + - [What is autocomplete? Why is it useful?](#what-is-autocomplete-why-is-it-useful) + - [Can I compose messages in another editor?](#can-i-compose-messages-in-another-editor) +- Something is not working! + - [Colors appear mismatched, don't change with theme, or look strange](#colors-appear-mismatched-dont-change-with-theme-or-look-strange) + - [Symbols look different to in the provided screenshots, or just look incorrect](#symbols-look-different-to-in-the-provided-screenshots-or-just-look-incorrect) + - [Unable to open links](#unable-to-open-links) + - [Mouse does not support *performing some action/feature*](#mouse-does-not-support-performing-some-actionfeature) + - [Hotkeys don't work as described](#hotkeys-dont-work-as-described) + - [Zulip-term crashed!](#zulip-term-crashed) + - [Something looks wrong! Where's this feature? There's a bug!](#something-looks-wrong-wheres-this-feature-theres-a-bug) + +## What Python implementations are supported? + +Users and developers run regularly with the "traditional" implementation of +Python (CPython). + +We also expect running with [PyPy](https://www.pypy.org) to be smooth based on +our automated testing, though it is worth noting we do not explicitly run the +application in these tests. + +Feedback on using these or any other implementations are welcome, such as those +[listed at python.org](https://www.python.org/download/alternatives/). + +## What Python versions are supported? + +Current practice is to pin minimum and maximum python version support. + +As a result: +- the current `main` branch in git supports Python 3.7 to 3.11 +- Release 0.7.0 was the last release to support Python 3.6 (maximum 3.10) +- Release 0.6.0 was the last release to support Python 3.5 (maximum 3.9) + +Note the minimum versions of Python are close to or in the unsupported range at +this time. + +The next release will include support for Python 3.11; before then we suggest +installing the +[latest (git) version](https://github.com/zulip/zulip-terminal/blob/main/README.md#installation). + +> Until Mid-April 2023 it is possible that installing with a very recent +> version of Python could lead to an apparently broken installation with a very +> old version of zulip-terminal. +> This should no longer be the case, now that these older versions have been +> yanked from PyPI, but could give symptoms resembling +> [issue 1145](https://github.com/zulip/zulip-terminal/issues/1145). + +## What operating systems are supported? + +We expect everything to work smoothly on the following operating systems: +* Linux +* macOS +* WSL (Windows Subsystem for Linux) + +These are covered in our automatic test suite, though it is assumed that Python +insulates us from excessive variations between distributions and versions, +including when using WSL. + +Note that some features are not supported or require extra configuration, +depending on the platform - see +[Configuration](https://github.com/zulip/zulip-terminal/blob/main/README.md#configuration). + +> NOTE: Windows is **not** natively supported right now, see +> [#357](https://github.com/zulip/zulip-terminal/issues/357). + +`Dockerfile`s have also been contributed, though we don't currently distribute +pre-built versions of these to install - see the [Docker +documentation](https://github.com/zulip/zulip-terminal/blob/main/docker/). + +## What versions of Zulip are supported? + +For the features that we support, we expect Zulip server versions as far +back as 2.1.0 to be usable. + +> NOTE: You can check your server version by pressing +> meta? in the application. + +Note that a subset of features in more recent Zulip versions are supported, and +could in some cases be present when using this client, particularly if the +feature relies upon a client-side implementation. + +## Will it run and display properly in my terminal (emulator)? We have reports of success on the following terminal emulators: @@ -21,15 +107,47 @@ We have reports of success on the following terminal emulators: * Terminator (https://github.com/gnome-terminator/terminator) * iterm2 (https://iterm2.com/) **Mac only** * mosh (https://mosh.org/) +* Alaccrity (https://github.com/alacritty/alacritty) Issues have been reported with the following: * (**major**) terminal app **Mac only** - - Issues with some default keypresses, including for sending messages [zulip-terminal#680](https://github.com/zulip/zulip-terminal/issues/680) -* (**minor**) Microsoft/Windows Terminal (https://github.com/Microsoft/Terminal) **Windows only** - - Bold text isn't actually bold, it either renders the same as normal text or renders in a different colour [microsoft/terminal#109](https://github.com/microsoft/terminal/issues/109) + - Issues with some default keypresses, including for sending messages + [zulip-terminal#680](https://github.com/zulip/zulip-terminal/issues/680) +* (**minor**) Microsoft/Windows Terminal + (https://github.com/Microsoft/Terminal) **Windows only** + - Bold text isn't actually bold, it either renders the same as normal text or + renders in a different colour + [microsoft/terminal#109](https://github.com/microsoft/terminal/issues/109) + +Please let us know if you have feedback on the success or failure in these or +any other terminal emulator! + +## How small a size of terminal is supported? + +While most users likely do not use such sizes, we aim to support sizes from the +standard 80 columns by 24 rows upwards. + +If you use a width from approximately 100 columns upwards, everything is +expected to work as documented. -Please let us know if you have feedback on the success or failure in these or any other terminal emulator! +However, since we currently use a fixed width for the left and right side +panels, for widths from approximately 80-100 columns the message list can +become too narrow. +In these situations we recommend using the `autohide` option in your +configuration file (see [configuration +file](https://github.com/zulip/zulip-terminal/#configuration) notes) or on the +command-line in a particular session via `--autohide`. + +If you use a small terminal size (or simply read long messages), you may find +it useful to read a message which is too long to fit in the window by opening +the Message Information (i) for a message and scrolling through the +Full rendered message (f). + +If you experience problems related to small sizes that are not resolved using +the above, please check +[#1005](https://www.github.com/zulip/zulip-terminal/issues/1005)) for any +unresolved such issues and report them there. ## Are there any themes available, other than the default one? @@ -40,24 +158,30 @@ Yes. There are five supported themes: - `zt_light` (alias: `light`) - `zt_blue` (alias: `blue`) -You can specify one of them on the command-line using the command-line option `--theme ` or `-t ` (where _theme_ is the name of the theme, or its alias). You can also specify it in the `zuliprc` file like this: +You can specify one of them on the command-line using the command-line option +`--theme ` or `-t ` (where _theme_ is the name of the theme, or +its alias). +You can also specify it in the `zuliprc` file like this: ``` [zterm] theme= ``` (where _theme_name_ is the name of theme or its alias). -**NOTE** Theme aliases are likely to be deprecated in the future, so we recommend using the full theme names. +**NOTE** Theme aliases are likely to be deprecated in the future, so we +recommend using the full theme names. ## How do links in messages work? What are footlinks? -Each link (hyperlink) in a Zulip message resembles those on the internet, and is split into two parts: -- the representation a user would see on the web page (eg. a textual description) +Each link (hyperlink) in a Zulip message resembles those on the internet, and +is split into two parts: +- the representation a user would see on the web page (eg. a textual + description) - the location the link would go to (if clicking, in a GUI web browser) To avoid squashing these two parts next to each other within the message -content, ZT places only the first within the content, followed by a number in square -brackets, eg. `Zulip homepage [1]`. +content, ZT places only the first within the content, followed by a number in +square brackets, eg. `Zulip homepage [1]`. Underneath the message content, each location is then listed next to the related number, eg. `1: zulip.com`. Within ZT we term these **footlinks**, @@ -98,8 +222,9 @@ content. The same scrolling keys as used elsewhere in the application can be used in this popup, and you may notice as you move that different lines of the popup -will be highlighted. If a link is highlighted and you press Enter, -an action may occur depending on the type of link: +will be highlighted. +If a link is highlighted and you press Enter, an action may occur +depending on the type of link: - *Attachments to a particular message* (eg. images, text files, pdfs, etc) * will be downloaded, with an option given to open it with your default application (from version 0.7.0) @@ -110,14 +235,14 @@ an action may occur depending on the type of link: * no internal action is supported at this time Any method supported by your terminal emulator to select and copy text should -also be suitable to extract these links. Some emulators can identify links to -open, and may do so in simple cases; however, while the popup is wider than the -message column, it will not fit all lengths of links, and so can fail in the -multiline case (see -[#622](https://www.github.com/zulip/zulip-terminal/issues/622)). Some emulators -may support area selection, as opposed to selecting multiple lines of the -terminal, but it's unclear how common this support is or if it converts such -text into one line suitable for use as a link. +also be suitable to extract these links. +Some emulators can identify links to open, and may do so in simple cases; +however, while the popup is wider than the message column, it will not fit all +lengths of links, and so can fail in the multiline case (see +[#622](https://www.github.com/zulip/zulip-terminal/issues/622)). +Some emulators may support area selection, as opposed to selecting multiple +lines of the terminal, but it's unclear how common this support is or if it +converts such text into one line suitable for use as a link. ## When are messages marked as having been read? @@ -125,61 +250,101 @@ The approach currently taken is that that a message is marked read when * it has been selected *or* * it is a message that you have sent -Unlike the web and mobile apps, we **don't** currently mark as read based on visibility, eg. if you have a message selected and all newer messages are also visible. This makes the marking-read process more explicit, balanced against needing to scroll through messages to mark them. Our styling is intended to promote moving onto unread messages to more easily read them. - -In contrast, like with the web app, we don't mark messages as read while in a search - but if you go to a message visible in a search within a topic or stream context then it will be marked as read, just like normal. - -An additional feature to other front-ends is **explore mode**, which can be enabled when starting the application (with `-e` or `--explore`); this allows browsing through all your messages and interacting within the application like normal, with the exception that messages are never marked as read. Other than providing a means to test the application with no change in state (ie. *explore* it), this can be useful to scan through your messages quickly when you intend to return to look at them properly later. +Unlike the web and mobile apps, we **don't** currently mark as read based on +visibility, eg. if you have a message selected and all newer messages are also +visible. +This makes the marking-read process more explicit, balanced against needing to +scroll through messages to mark them. +Our styling is intended to promote moving onto unread messages to more easily +read them. + +In contrast, like with the web app, we don't mark messages as read while in a +search - but if you go to a message visible in a search within a topic or +stream context then it will be marked as read, just like normal. + +An additional feature to other front-ends is **explore mode**, which can be +enabled when starting the application (with `-e` or `--explore`); this allows +browsing through all your messages and interacting within the application like +normal, with the exception that messages are never marked as read. +Other than providing a means to test the application with no change in state +(ie. *explore* it), this can be useful to scan through your messages quickly +when you intend to return to look at them properly later. ## How do I access multiple servers? -One session of Zulip Terminal represents a connection of one user to one Zulip server. Each session refers to a zuliprc file to identify how to connect to the server, so by running Zulip Terminal and specifying a different zuliprc file (using `-c` or `--config-file`), you may connect to a different server. You might choose to do that after exiting from one Zulip Terminal session, or open another terminal and run it concurrently there. - -Since we expect the above to be straightforward for most users and it allows the code to remain dramatically simpler, we are unlikely to support multiple Zulip servers within the same session in at least the short/medium term. -However, we are certainly likely to move towards a system to make access of the different servers simpler, which should be made easier through work such as [zulip-terminal#678](https://github.com/zulip/zulip-terminal/issues/678). -In the longer term we may move to multiple servers per session, which is tracked in [zulip-terminal#961](https://github.com/zulip/zulip-terminal/issues/961). +One session of Zulip Terminal represents a connection of one user to one Zulip +server. +Each session refers to a zuliprc file to identify how to connect to the server, +so by running Zulip Terminal and specifying a different zuliprc file (using +`-c` or `--config-file`), you may connect to a different server. +You might choose to do that after exiting from one Zulip Terminal session, or +open another terminal and run it concurrently there. + +Since we expect the above to be straightforward for most users and it allows +the code to remain dramatically simpler, we are unlikely to support multiple +Zulip servers within the same session in at least the short/medium term. +However, we are certainly likely to move towards a system to make access of the +different servers simpler, which should be made easier through work such as +[zulip-terminal#678](https://github.com/zulip/zulip-terminal/issues/678). +In the longer term we may move to multiple servers per session, which is +tracked in +[zulip-terminal#961](https://github.com/zulip/zulip-terminal/issues/961). ## What is autocomplete? Why is it useful? -Autocomplete can be used to request matching options, and cycle through each option in turn, including: +Autocomplete can be used to request matching options, and cycle through each +option in turn, including: - helping to specify users for new direct messages (eg. after x) -- helping to specify streams and existing topics for new stream messages (eg. after c) +- helping to specify streams and existing topics for new stream messages (eg. + after c) - mentioning a user or user-group (in message content) - linking to a stream or existing topic (in message content) - emojis (in message content) This helps ensure that: -- messages are sent to valid users, streams, and matching existing topics if appropriate - so they - are sent to the correct location; -- message content has references to valid users, user-groups, streams, topics and emojis, with - correct syntax - so is rendered well in all Zulip clients. +- messages are sent to valid users, streams, and matching existing topics if + appropriate - so they are sent to the correct location; +- message content has references to valid users, user-groups, streams, topics + and emojis, with correct syntax - so is rendered well in all Zulip clients. > Note that if using the left or right panels of the application to search for -> streams, topics or users, this is **not** part of the autocomplete system. In -> those cases, as you type, the results of these searches are shown +> streams, topics or users, this is **not** part of the autocomplete system. +> In those cases, as you type, the results of these searches are shown > automatically by limiting what is displayed in the existing area, until the -> search is cleared. Autocomplete operates differently, and uses the bottom -> line(s) of the screen to show a limited set of matches. +> search is cleared. +> Autocomplete operates differently, and uses the bottom line(s) of the screen +> to show a limited set of matches. ### Hotkeys triggering autocomplete -We use ctrl+**f** and ctrl+**r** for cycling through autocompletes (**forward** & **reverse** respectively). +We use ctrl+**f** and ctrl+**r** +for cycling through autocompletes (**forward** & **reverse** respectively). -**NOTE:** We don't use tab/shift+tab (although it is widely used elsewhere) for cycling through matches. However, recall that we do use tab to cycle through recipient and content boxes. (See [hotkeys for composing a message](https://github.com/zulip/zulip-terminal/blob/main/docs/hotkeys.md#composing-a-message)) +**NOTE:** We don't use tab/shift+tab (although +it is widely used elsewhere) for cycling through matches. +However, recall that we do use tab to cycle through recipient and +content boxes. (See +[hotkeys for composing a message](https://github.com/zulip/zulip-terminal/blob/main/docs/hotkeys.md#composing-a-message)) ### Example: Using autocomplete to add a recognized emoji in your message content -1. Type a prefix designated for an autocomplete (e.g., `:` for autocompleting emojis). -2. Along with the prefix, type the initial letters of the text (e.g., `air` for `airplane`). -3. Now hit the hotkey. You'd see suggestions being listed in the footer (a maximum of 10) if there are any. -4. Cycle between different suggestions as described in above hotkey section. (Notice that a selected suggestion is highlighted) -5. Cycling past the end of suggestions goes back to the prefix you entered (`:air` for this case). +1. Type a prefix designated for an autocomplete (e.g., `:` for autocompleting + emojis). +2. Along with the prefix, type the initial letters of the text (e.g., `air` for + `airplane`). +3. Now hit the hotkey. You'd see suggestions being listed in the footer (a + maximum of 10) if there are any. +4. Cycle between different suggestions as described in above hotkey section. + (Notice that a selected suggestion is highlighted) +5. Cycling past the end of suggestions goes back to the prefix you entered + (`:air` for this case). ![selected_footer_autocomplete](https://user-images.githubusercontent.com/55916430/116669526-53cfb880-a9bc-11eb-8073-11b220e6f15a.gif) ### Autocomplete in the message content -As in the example above, a specific prefix is required to indicate which action to perform (what text to insert) via the autocomplete: +As in the example above, a specific prefix is required to indicate which action +to perform (what text to insert) via the autocomplete: |Action|Prefix text(s)|Autocompleted text format| | :--- | :---: | :---: | @@ -192,7 +357,9 @@ As in the example above, a specific prefix is required to indicate which action ### Autocomplete of message recipients -Since each of the stream (1), topic (2) and direct message recipients (3) areas are very specific, no prefix must be manually entered and values provided through autocomplete depend upon the context automatically. +Since each of the stream (1), topic (2) and direct message recipients (3) areas +are very specific, no prefix must be manually entered and values provided +through autocomplete depend upon the context automatically. ![Stream header](https://user-images.githubusercontent.com/55916430/118403323-8e5b7580-b68b-11eb-9c8a-734c2fe6b774.png) @@ -204,62 +371,129 @@ Since each of the stream (1), topic (2) and direct message recipients (3) areas ![PM recipients header](https://user-images.githubusercontent.com/55916430/118403345-9d422800-b68b-11eb-9005-6d2af74adab9.png) -**NOTE:** If a direct message recipient's name contains comma(s) (`,`), they are currently treated as comma-separated recipients. +**NOTE:** If a direct message recipient's name contains comma(s) (`,`), they +are currently treated as comma-separated recipients. -## Unable to render symbols +## Can I compose messages in another editor? -If some symbols don't render properly on your terminal, it could likely be because of the symbols not being supported on your terminal emulator and/or font. +In the `main` branch of zulip-terminal, you can now use an external editor of +your choice to compose your messages using the `Ctrl o` hotkey. -We provide a tool that you can run with the command `zulip-term-check-symbols` to check whether or not the symbols render properly on your terminal emulator and font configuration. +The editor command is looked for in the `ZULIP_EDITOR_COMMAND` and `EDITOR` +environment variables (in that order), or the `editor` entry of the zuliprc +file (which overrides both of those). -Ideally, you should see something similar to the following screenshot (taken on the GNOME Terminal) as a result of running the tool: +It should work directly for most terminal editors with only the program name, +eg. `vim`, `nano`, `helix`, `kakoune`, `nvim`, etc. -![Render Symbols Screenshot](https://user-images.githubusercontent.com/60441372/115103315-9a5df580-9f6e-11eb-8c90-3b2585817d08.png) +It can also be used with a desktop editor with some constraints: the program +must not fork or detach from the running terminal and should open in a new +window. For this reason it may be useful to use `ZULIP_EDITOR_COMMAND` (or the +zuliprc setting) to avoid conflict with the more widely used `EDITOR` +environment variable. Some examples include: -If you are unable to observe a similar result upon running the tool, please take a screenshot and let us know about it along with your terminal and font configuration by opening an issue or at [#zulip-terminal](https://chat.zulip.org/#narrow/stream/206-zulip-terminal). +- [lapce](https://github.com/lapce/lapce) with `lapce -n -w` +- [sublime-text](https://www.sublimetext.com/) with `subl -n -w` +- [marker](https://github.com/fabiocolacio/Marker) with `marker` +- [vim](https://github.com/vim/vim) with `vim -g -f` or `gvim -f` +- [vscode](https://github.com/microsoft/vscode) with `code -n -w` -## Unable to open links +**NOTE:** Backslashing white space (`\ `) is needed when using an executable +containing them, for example Sublime Text on macOS can be configured with +`/Applications/Sublime\ Text.app/Contents/SharedSupport/bin/subl`. -If you are unable to open links in messages, then try double right-click on the link. +This feature works by sending the message content back and forth using +temporary files: +- A temporary file is created, filled with any existing message content; +- The editor command is run, with the filename appended (standard practice for editors); +- You edit your text in the editor of your choice; +- When the external editor process ends (closing the window, or quitting the terminal +editor), the compose box will be updated with the new message content from +the temporary file. -Alternatively, you might try different modifier keys (eg. shift, ctrl, alt) with a right-click. +> NOTE 1: If using a terminal-based editor, it will temporarily take over the +> entire terminal and replace the application. However, upon exiting that +> editor, the application should return to the terminal. -If you are still facing problems, please discuss them at -[#zulip-terminal](https://chat.zulip.org/#narrow/stream/206-zulip-terminal) or open issues -for them mentioning your terminal name, version, and OS. +> NOTE 2: Whichever type of editor you use (terminal or desktop/graphical), the +> application will temporarily pause while you are editing. The application +> should resume as normal once you exit the external editor and the text you +> entered appears in the compose area. -## How small a size of terminal is supported? +**If you have success with other editors, these examples do not work, or have +other problems, please let us know!** -While most users likely do not use such sizes, we aim to support sizes from the standard 80 columns by 24 rows upwards. +## Colors appear mismatched, don't change with theme, or look strange -If you use a width from approximately 100 columns upwards, everything is expected to work as documented. +Some terminal emulators support specifying custom colors, or custom color +schemes. If you do this then this can override the colors that Zulip Terminal +attempts to use. -However, since we currently use a fixed width for the left and right side panels, for widths from approximately 80-100 columns the message list can become too narrow. -In these situations we recommend using the `autohide` option in your configuration file (see [configuration file](https://github.com/zulip/zulip-terminal/#configuration) notes) or on the command-line in a particular session via `--autohide`. +**NOTE** If you have color issues, also note that urwid version 2.1.1 should +have fixed these for various terminal emulators (including rxvt, urxvt, mosh +and Terminal.app), so please ensure you are running the latest Zulip Terminal +release and at least this urwid version before reporting issues. -If you use a small terminal size (or simply read long messages), you may find -it useful to read a message which is too long to fit in the window by opening -the Message Information (i) for a message and scrolling through the Full -rendered message (f). +## Symbols look different to in the provided screenshots, or just look incorrect + +If some symbols don't render properly on your terminal, it could likely be +because of the symbols not being supported on your terminal emulator and/or +font. + +We provide a tool that you can run with the command `zulip-term-check-symbols` +to check whether or not the symbols render properly on your terminal emulator +and font configuration. + +Ideally, you should see something similar to the following screenshot (taken on +the GNOME Terminal) as a result of running the tool: -If you experience problems related to small sizes that are not resolved using the above, please check [#1005](https://www.github.com/zulip/zulip-terminal/issues/1005)) for any unresolved such issues and report them there. +![Render Symbols Screenshot](https://user-images.githubusercontent.com/60441372/115103315-9a5df580-9f6e-11eb-8c90-3b2585817d08.png) + +If you are unable to observe a similar result upon running the tool, please +take a screenshot and let us know about it along with your terminal and font +configuration by opening an issue or at +[#zulip-terminal](https://chat.zulip.org/#narrow/stream/206-zulip-terminal). + +## Unable to open links + +If you are unable to open links in messages, then try double right-click on the +link. + +Alternatively, you might try different modifier keys (eg. shift, ctrl, alt) +with a right-click. + +If you are still facing problems, please discuss them at +[#zulip-terminal](https://chat.zulip.org/#narrow/stream/206-zulip-terminal) or +open issues for them mentioning your terminal name, version, and OS. ## Mouse does not support *performing some action/feature* -We think of Zulip Terminal as a keyboard-centric client. Consequently, while functionality via the mouse does work in places, mouse support is not currently a priority for the project (see also [#248](https://www.github.com/zulip/zulip-terminal/issues/248)). +We think of Zulip Terminal as a keyboard-centric client. +Consequently, while functionality via the mouse does work in places, mouse +support is not currently a priority for the project (see also +[#248](https://www.github.com/zulip/zulip-terminal/issues/248)). ## Hotkeys don't work as described -If any of the hotkeys don't work for you, feel free to open an issue or discuss it on [#zulip-terminal](https://chat.zulip.org/#narrow/stream/206-zulip-terminal). +If any of the hotkeys don't work for you, feel free to open an issue or discuss +it on +[#zulip-terminal](https://chat.zulip.org/#narrow/stream/206-zulip-terminal). ## Zulip-term crashed! -We hope this doesn't happen, but would love to hear about this in order to fix it, since the application should be increasingly stable! Please let us know the problem, and if you're able to duplicate the issue, on the github issue-tracker or at [#zulip-terminal](https://chat.zulip.org/#narrow/stream/206-zulip-terminal). +We hope this doesn't happen, but would love to hear about this in order to fix +it, since the application should be increasingly stable! +Please let us know the problem, and if you're able to duplicate the issue, on +the github issue-tracker or at +[#zulip-terminal](https://chat.zulip.org/#narrow/stream/206-zulip-terminal). -This process would be helped if you could send us the 'traceback' showing the cause of the error, which should be output in such cases: +This process would be helped if you could send us the 'traceback' showing the +cause of the error, which should be output in such cases: * version 0.3.1 and earlier: the error is shown on the terminal; * versions 0.3.2+: the error is present/appended to the file `zulip-terminal-tracebacks.log`. ## Something looks wrong! Where's this feature? There's a bug! -Come meet us on the [#zulip-terminal](https://chat.zulip.org/#narrow/stream/206-zulip-terminal) stream on *chat.zulip.org*. +Come meet us on the +[#zulip-terminal](https://chat.zulip.org/#narrow/stream/206-zulip-terminal) +stream on *chat.zulip.org*. diff --git a/docs/developer-file-overview.md b/docs/developer-file-overview.md index ef21a7c9fb..d488227643 100644 --- a/docs/developer-file-overview.md +++ b/docs/developer-file-overview.md @@ -17,6 +17,7 @@ Zulip Terminal uses [Zulip's API](https://zulip.com/api/) to store and retrieve | | unicode_emojis.py | Unicode emoji data, synchronized semi-regularly with the server source | | | urwid_types.py | Types from the urwid API, to improve type checking | | | version.py | Keeps track of the version of the current code | +| | widget.py | Process widgets (submessages) like polls, todo lists, etc. | | | | | | zulipterminal/cli | run.py | Marks the entry point into the application | | | | | diff --git a/docs/hotkeys.md b/docs/hotkeys.md index db4972036f..43bbf6125a 100644 --- a/docs/hotkeys.md +++ b/docs/hotkeys.md @@ -5,95 +5,135 @@ ## General |Command|Key Combination| | :--- | :---: | -|Show/hide help menu|?| -|Show/hide markdown help menu|meta + m| -|Show/hide about menu|meta + ?| -|Go Back|esc| -|Open draft message saved in this session|d| -|Redraw screen|ctrl + l| -|Quit|ctrl + c| -|View user information (From Users list)|i| +|Show/hide Help Menu|?| +|Show/hide Markdown Help Menu|Meta + m| +|Show/hide About Menu|Meta + ?| +|Copy information from About Menu to clipboard|c| +|Copy traceback from Exception Popup to clipboard|c| +|Redraw screen|Ctrl + l| +|Quit|Ctrl + c| +|New footer hotkey hint|Tab| ## Navigation |Command|Key Combination| | :--- | :---: | -|Go up / Previous message|up / k| -|Go down / Next message|down / j| -|Go left|left / h| -|Go right|right / l| -|Scroll up|page + up / K| -|Scroll down|page + down / J| -|Go to bottom / Last message|end / G| -|Narrow to all messages|a / esc| -|Narrow to all direct messages|P| -|Narrow to all starred messages|f| -|Narrow to messages in which you're mentioned|#| +|Close popup|Esc| +|Go up / Previous message|Up / k| +|Go down / Next message|Down / j| +|Go left|Left / h| +|Go right|Right / l| +|Scroll up|PgUp / K| +|Scroll down|PgDn / J| +|Go to bottom / Last message|End / G| +|Trigger the selected entry|Enter / Space| + +## Switching Messages View +|Command|Key Combination| +| :--- | :---: | +|View the stream of the current message|s| +|View the topic of the current message|S| +|Zoom in/out the message's conversation context|z| +|Switch message view to the compose box target|Meta + .| +|View all messages|a / Esc| +|View all direct messages|P| +|View all starred messages|f| +|View all messages in which you're mentioned|#| |Next unread topic|n| |Next unread direct message|p| -|Perform current action|enter| ## Searching |Command|Key Combination| | :--- | :---: | -|Search Users|w| -|Search Messages|/| -|Search Streams|q| +|Search users|w| +|Search messages|/| +|Search streams|q| |Search topics in a stream|q| -|Search emojis from Emoji-picker popup|p| +|Search emojis from emoji picker|p| +|Submit search and browse results|Enter| +|Clear search in current panel|Esc| ## Message actions |Command|Key Combination| | :--- | :---: | -|Reply to the current message|r / enter| -|Reply mentioning the sender of the current message|@| -|Reply quoting the current message text|>| -|Reply directly to the sender of the current message|R| |Edit message's content or topic|e| -|New message to a stream|c| -|New message to a person or group of people|x| -|Show/hide Emoji picker popup for current message|:| -|Narrow to the stream of the current message|s| -|Narrow to the topic of the current message|S| -|Narrow to a topic/direct-chat, or stream/all-direct-messages|z| -|Add/remove thumbs-up reaction to the current message|+| -|Add/remove star status of the current message|ctrl + s / *| +|Show/hide emoji picker for current message|:| +|Toggle first emoji reaction on selected message|=| +|Toggle thumbs-up reaction to the current message|+| +|Toggle star status of the current message|Ctrl + s / *| |Show/hide message information|i| -|Show/hide edit history (from message information)|e| -|View current message in browser (from message information)|v| -|Show/hide full rendered message (from message information)|f| -|Show/hide full raw message (from message information)|r| +|Show/hide message sender information|u| ## Stream list actions |Command|Key Combination| | :--- | :---: | |Toggle topics in a stream|t| -|Mute/unmute Streams|m| +|Mute/unmute streams|m| |Show/hide stream information & modify settings|i| -|Show/hide stream members (from stream information)|m| -|Copy stream email to clipboard (from stream information)|c| -## Composing a Message +## User list actions +|Command|Key Combination| +| :--- | :---: | +|Show/hide user information|i| +|Narrow to direct messages with user|Enter| + +## Begin composing a message +|Command|Key Combination| +| :--- | :---: | +|Open draft message saved in this session|d| +|Reply to the current message|r / Enter| +|Reply mentioning the sender of the current message|@| +|Reply quoting the current message text|>| +|Reply directly to the sender of the current message|R| +|New message to a stream|c| +|New message to a person or group of people|x| + +## Writing a message +|Command|Key Combination| +| :--- | :---: | +|Cycle through recipient and content boxes|Tab| +|Send a message|Ctrl + d / Meta + Enter| +|Save current message as a draft|Meta + s| +|Autocomplete @mentions, #stream_names, :emoji: and topics|Ctrl + f| +|Cycle through autocomplete suggestions in reverse|Ctrl + r| +|Exit message compose box|Esc| +|Insert new line|Enter| +|Open an external editor to edit the message content|Ctrl + o| + +## Editor: Navigation +|Command|Key Combination| +| :--- | :---: | +|Start of line|Ctrl + a / Home| +|End of line|Ctrl + e / End| +|Start of current or previous word|Meta + b / Shift + Left| +|Start of next word|Meta + f / Shift + Right| +|Previous line|Up / Ctrl + p| +|Next line|Down / Ctrl + n| + +## Editor: Text Manipulation +|Command|Key Combination| +| :--- | :---: | +|Undo last action|Ctrl + _| +|Clear text box|Ctrl + l| +|Cut forwards to the end of the line|Ctrl + k| +|Cut backwards to the start of the line|Ctrl + u| +|Cut forwards to the end of the current word|Meta + d| +|Cut backwards to the start of the current word|Ctrl + w / Meta + Backspace| +|Cut the current line|Meta + x| +|Paste last cut section|Ctrl + y| +|Delete previous character|Ctrl + h| +|Swap with previous character|Ctrl + t| + +## Stream information (press i to view info of a stream) +|Command|Key Combination| +| :--- | :---: | +|Show/hide stream members|m| +|Copy stream email to clipboard|c| + +## Message information (press i to view info of a message) |Command|Key Combination| | :--- | :---: | -|Cycle through recipient and content boxes|tab| -|Send a message|ctrl + d / meta + enter| -|Save current message as a draft|meta + s| -|Autocomplete @mentions, #stream_names, :emoji: and topics|ctrl + f| -|Cycle through autocomplete suggestions in reverse|ctrl + r| -|Narrow to compose box message recipient|meta + .| -|Jump to the beginning of line|ctrl + a| -|Jump to the end of line|ctrl + e| -|Jump backward one word|meta + b| -|Jump forward one word|meta + f| -|Delete previous character (to left)|ctrl + h| -|Transpose characters|ctrl + t| -|Cut forwards to the end of the line|ctrl + k| -|Cut backwards to the start of the line|ctrl + u| -|Cut forwards to the end of the current word|meta + d| -|Cut backwards to the start of the current word|ctrl + w| -|Paste last cut section|ctrl + y| -|Undo last action|ctrl + _| -|Jump to the previous line|up / ctrl + p| -|Jump to the next line|down / ctrl + n| -|Clear compose box|ctrl + l| +|Show/hide edit history|e| +|View current message in browser|v| +|Show/hide full rendered message|f| +|Show/hide full raw message|r| diff --git a/pyproject.toml b/pyproject.toml index 7a4c6b7f8d..f01695735c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,14 @@ precision = 1 skip_covered = true show_missing = true +[tool.typos.default.extend-identifiers] +# See crate-ci/typos#744 and crate-ci/typos#773 +"O_WRONLY" = "O_WRONLY" + +[tool.typos.default.extend-words] +# Allow iterm terminal emulator (ideally only per-file) +"iterm" = "iterm" + [tool.isort] py_version = 37 # Use black for the default settings (with adjustments listed below) @@ -47,7 +55,27 @@ scripts_are_modules = true show_traceback = true cache_dir = ".mypy_cache" -# Options to make the checking stricter, as would be mypy --strict +# Non-strict dynamic typing (unlikely to be enabled) +disallow_any_unimported = false +disallow_any_expr = false +disallow_any_decorated = false +disallow_any_explicit = false # May be useful to improve checking of non-UI files + +# Optional handling (mypy defaults) +no_implicit_optional = true +strict_optional = true + +# Warnings (mypy defaults) +warn_no_return = true + +# Misc strictness (mypy defaults) +disallow_untyped_globals = true +disallow_redefinition = true + +# Warnings not in strict +warn_unreachable = true + +# Options to make the checking stricter, as included in mypy --strict # (in order of mypy --help docs for --strict) warn_unused_configs = true disallow_any_generics = true @@ -56,19 +84,43 @@ disallow_untyped_calls = false disallow_untyped_defs = true disallow_incomplete_defs = true check_untyped_defs = true -disallow_untyped_decorators = false -no_implicit_optional = true +disallow_untyped_decorators = true warn_redundant_casts = true warn_unused_ignores = true warn_return_any = false -no_implicit_reexport = false +no_implicit_reexport = true # NOTE: Disabled explicitly for tests/ in run-mypy strict_equality = true -# These are now defaults and we could remove them -warn_no_return = true -strict_optional = true +extra_checks = true + +enable_error_code = [ + "redundant-expr", + "truthy-bool", + "truthy-iterable", + "ignore-without-code", + "unused-awaitable", # Even if await unused, it may be in future +] + +# Follow imports to look for typing information, erroring if not present +follow_imports = "normal" +ignore_missing_imports = false + +[[tool.mypy.overrides]] +# Libraries used in main application, for which typing is unavailable +# NOTE: When missing imports are ignored, all types are set to be Any +module = [ + "urwid.*", # Minimal typing of this is in urwid_types.py + "urwid_readline", # Typing this likely depends on typing urwid + "pyperclip", # Hasn't been updated in some time, unlikely to be typed + "pudb", # This is barely used & could be optional/dev dependency +] +ignore_missing_imports = true -# If a library is typed, that's fine, otherwise don't worry +[[tool.mypy.overrides]] +# Development-only tools, for which typing is unavailable +module = [ + "gitlint.*", # This lack of typing should only impact our custom rules +] ignore_missing_imports = true [[tool.mypy.overrides]] @@ -113,6 +165,7 @@ ignore = [ "B007", # Loop control variable not used within the loop body [informative!] "C408", # Unnecessary 'dict' call (rewrite as a literal) "N818", # Exception name should be named with an Error suffix + "PLC1901", # Allow explicit falsey checks in tests, eg. x == "" vs not x "SIM108", # Prefer ternary operator over if-else (as per zulip/zulip) "UP015", # Unnecessary open mode parameters [informative?] ] @@ -144,8 +197,6 @@ select = [ "tests/model/test_model.py" = ["ANN"] "tests/ui/test_ui_tools.py" = ["ANN"] "tests/ui_tools/test_messages.py" = ["ANN"] -# ANN: This tool is an old variation from zulip/zulip and is only updated where necessary -"tools/lister.py" = ["ANN"] # E501: Ignore length in tools for now (and otherwise where noqa specified) # T20: Expect print output from CLI from tools "tools/*" = ["E501", "T20"] diff --git a/setup.py b/setup.py index 2c879056d8..168f33dd33 100644 --- a/setup.py +++ b/setup.py @@ -17,23 +17,33 @@ def long_description(): return "\n".join(source.splitlines()[1:]) -testing_deps = [ +testing_minimal_deps = [ "pytest~=7.2.0", - "pytest-cov~=4.0.0", "pytest-mock~=3.10.0", ] +testing_plugin_deps = [ + "pytest-cov~=4.0.0", +] + +testing_deps = testing_minimal_deps + testing_plugin_deps + linting_deps = [ "isort~=5.11.0", "black~=23.0", - "ruff==0.0.253", - "codespell[toml]~=2.2.2", - "typos~=1.13.20", + "ruff==0.0.267", + "codespell[toml]~=2.2.5", + "typos~=1.16.11", +] + +gitlint_deps = [ + "gitlint~=0.18.0", ] typing_deps = [ "lxml-stubs", - "mypy~=1.0.0", + "mypy~=1.8.0", + "types-beautifulsoup4", "types-pygments", "types-python-dateutil", "types-tzlocal", @@ -41,10 +51,9 @@ def long_description(): "types-requests", ] -dev_helper_deps = [ +helper_deps = [ "pudb==2022.1.1", "snakeviz>=2.1.1", - "gitlint~=0.18.0", ] setup( @@ -87,9 +96,11 @@ def long_description(): ], }, extras_require={ - "dev": testing_deps + linting_deps + typing_deps + dev_helper_deps, + "dev": testing_deps + linting_deps + typing_deps + helper_deps + gitlint_deps, "testing": testing_deps, + "testing-minimal": testing_minimal_deps, # extra must be hyphenated "linting": linting_deps, + "gitlint": gitlint_deps, "typing": typing_deps, }, tests_require=testing_deps, @@ -98,8 +109,8 @@ def long_description(): "zulip>=0.8.2", "urwid_readline>=0.13", "beautifulsoup4>=4.11.1", - "lxml>=4.9.2", - "pygments>=2.14.0", + "lxml~=4.9.2", + "pygments>=2.14.0,<2.18.0", "typing_extensions~=4.5.0", "python-dateutil>=2.8.2", "pytz>=2022.7.1", diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py index 9fafdab363..bf24a9b5a1 100644 --- a/tests/cli/test_run.py +++ b/tests/cli/test_run.py @@ -5,13 +5,17 @@ from typing import Callable, Dict, Generator, List, Optional, Tuple import pytest +import requests from pytest import CaptureFixture from pytest_mock import MockerFixture +from zulipterminal.api_types import ServerSettings from zulipterminal.cli.run import ( + NotAZulipOrganizationError, _write_zuliprc, exit_with_error, - get_login_id, + get_login_label, + get_server_settings, in_color, main, parse_args, @@ -23,6 +27,8 @@ MODULE = "zulipterminal.cli.run" CONTROLLER = MODULE + ".Controller" +os.environ["ZULIP_EDITOR_COMMAND"] = "" + @pytest.mark.parametrize( "color, code", @@ -54,15 +60,64 @@ def test_in_color(color: str, code: str, text: str = "some text") -> None: (dict(require_email_format_usernames=True, email_auth_enabled=False), "Email"), ], ) -def test_get_login_id(mocker: MockerFixture, json: Dict[str, bool], label: str) -> None: - mocked_styled_input = mocker.patch( - MODULE + ".styled_input", return_value="input return value" +def test_get_login_label( + mocker: MockerFixture, + json: ServerSettings, # NOTE: pytest does not ensure dict above is complete + label: str, +) -> None: + result = get_login_label(json) + assert result == label + ": " + + +@pytest.fixture +def server_settings_minimal() -> ServerSettings: + return ServerSettings( + authentication_methods={}, + external_authentication_methods=[], + zulip_feature_level=0, # New in Zulip 3.0, ZFL 1 + zulip_version="2.1.0", + zulip_merge_base="", # New in Zulip 5.0, ZFL 88 + push_notifications_enabled=True, + is_incompatible=False, + require_email_format_usernames=False, + email_auth_enabled=True, + realm_uri="chat.zulip.zulip", # Present if a Zulip server; preferred URL + realm_name="A Zulip Server", # Present if Organization is active at URL + realm_icon="...", + realm_description="Very exciting server", + realm_web_public_access_enabled=True, # New in Zulip 5.0, ZFL 116 ) - result = get_login_id(json) - assert result == "input return value" - mocked_styled_input.assert_called_with(label + ": ") +def test_get_server_settings( + mocker: MockerFixture, + server_settings_minimal: ServerSettings, + realm_url: str = "https://chat.zulip.org", +) -> None: + response = mocker.Mock( + status_code=requests.codes.OK, json=lambda: server_settings_minimal + ) + mocked_get = mocker.patch("requests.get", return_value=response) + + result = get_server_settings(realm_url) + + mocked_get.assert_called_once_with(url=realm_url + "/api/v1/server_settings") + assert result == server_settings_minimal + + +def test_get_server_settings__not_a_zulip_organization( + mocker: MockerFixture, realm_url: str = "https://google.com" +) -> None: + response = mocker.Mock( + status_code=requests.codes.bad_request # FIXME: Test others? + ) + mocked_get = mocker.patch("requests.get", return_value=response) + + with pytest.raises(NotAZulipOrganizationError) as exc: + get_server_settings(realm_url) + + mocked_get.assert_called_once_with(url=realm_url + "/api/v1/server_settings") + assert str(exc.value) == realm_url @pytest.mark.parametrize("options", ["-h", "--help"]) @@ -90,6 +145,8 @@ def test_main_help(capsys: CaptureFixture[str], options: str) -> None: "--color-depth", "--notify", "--no-notify", + "--transparency", + "--no-transparency", } optional_argument_lines = { line[2:] for line in lines if len(line) > 2 and line[2] == "-" @@ -100,6 +157,16 @@ def test_main_help(capsys: CaptureFixture[str], options: str) -> None: assert captured.err == "" +@pytest.fixture +def platform_mocker(mocker: MockerFixture) -> Callable[[str, str], List[str]]: + def factory(platform: str, python: str) -> List[str]: + mocker.patch(MODULE + ".detected_platform", return_value=platform) + mocker.patch(MODULE + ".detected_python_in_full", return_value=python) + return ["Detected:", f" - platform: {platform}", f" - python: {python}"] + + return factory + + @pytest.fixture def minimal_zuliprc(tmp_path: Path) -> str: zuliprc_path = tmp_path / "zuliprc" @@ -112,15 +179,17 @@ def minimal_zuliprc(tmp_path: Path) -> str: def test_valid_zuliprc_but_no_connection( capsys: CaptureFixture[str], mocker: MockerFixture, + platform_mocker: Callable[[str, str], List[str]], minimal_zuliprc: str, server_connection_error: str = "some_error", platform: str = "some_platform", + python: str = "3.99 (Zython) [cool]", ) -> None: mocker.patch( CONTROLLER + ".__init__", side_effect=ServerConnectionFailure(server_connection_error), ) - mocker.patch(MODULE + ".detected_platform", return_value=platform) + expected_platform_output = platform_mocker(platform, python) with pytest.raises(SystemExit) as e: main(["-c", minimal_zuliprc]) @@ -130,14 +199,16 @@ def test_valid_zuliprc_but_no_connection( captured = capsys.readouterr() lines = captured.out.strip().split("\n") - expected_lines = [ - f"Detected platform: {platform}", + expected_lines = expected_platform_output + [ "Loading with:", " theme 'zt_dark' specified from default config.", " autohide setting 'no_autohide' specified from default config.", + " exit confirmation setting 'enabled' specified from default config.", " maximum footlinks value '3' specified from default config.", " color depth setting '256' specified from default config.", " notify setting 'disabled' specified from default config.", + " transparency setting 'disabled' specified from default config.", + " external editor command '' specified from environment.", "\x1b[91m", f"Error connecting to Zulip server: {server_connection_error}.\x1b[0m", ] @@ -156,12 +227,14 @@ def test_valid_zuliprc_but_no_connection( def test_warning_regarding_incomplete_theme( capsys: CaptureFixture[str], mocker: MockerFixture, + platform_mocker: Callable[[str, str], List[str]], minimal_zuliprc: str, bad_theme: str, expected_complete_incomplete_themes: Tuple[List[str], List[str]], expected_warning: str, server_connection_error: str = "sce", platform: str = "some_platform", + python: str = "3.99 (Zython) [cool]", ) -> None: mocker.patch( CONTROLLER + ".__init__", @@ -175,6 +248,8 @@ def test_warning_regarding_incomplete_theme( ) mocker.patch(MODULE + ".generate_theme") + expected_platform_output = platform_mocker(platform, python) + with pytest.raises(SystemExit) as e: main(["-c", minimal_zuliprc, "-t", bad_theme]) @@ -183,16 +258,18 @@ def test_warning_regarding_incomplete_theme( captured = capsys.readouterr() lines = captured.out.strip().split("\n") - expected_lines = [ - f"Detected platform: {platform}", + expected_lines = expected_platform_output + [ "Loading with:", f" theme '{bad_theme}' specified on command line.", "\x1b[93m WARNING: Incomplete theme; results may vary!", f" {expected_warning}\x1b[0m", " autohide setting 'no_autohide' specified from default config.", + " exit confirmation setting 'enabled' specified from default config.", " maximum footlinks value '3' specified from default config.", " color depth setting '256' specified from default config.", " notify setting 'disabled' specified from default config.", + " transparency setting 'disabled' specified from default config.", + " external editor command '' specified from environment.", "\x1b[91m", f"Error connecting to Zulip server: {server_connection_error}.\x1b[0m", ] @@ -321,6 +398,7 @@ def test_main_cannot_write_zuliprc_given_good_credentials( # This is default base path to use zuliprc_path = os.path.join(str(tmp_path), path_to_use) + zuliprc_file = os.path.join(zuliprc_path, "zuliprc") monkeypatch.setenv("HOME", zuliprc_path) # Give some arbitrary input and fake that it's always valid @@ -335,12 +413,18 @@ def test_main_cannot_write_zuliprc_given_good_credentials( captured = capsys.readouterr() lines = captured.out.strip().split("\n") - expected_line = ( - "\x1b[91m" - f"{expected_exception}: zuliprc could not be created " - f"at {os.path.join(zuliprc_path, 'zuliprc')}" - "\x1b[0m" - ) + if expected_exception == "FileNotFoundError": + expected_error = ( + f"could not create {zuliprc_file} " + f"([Errno 2] No such file or directory: '{zuliprc_file}')" + ) + else: # PermissionError + expected_error = ( + f"could not create {zuliprc_file} " + f"([Errno 13] Permission denied: '{zuliprc_file}')" + ) + + expected_line = f"\x1b[91m{expected_exception}: {expected_error}\x1b[0m" assert lines[-1] == expected_line @@ -377,14 +461,14 @@ def func(config: Dict[str, str]) -> str: def test_successful_main_function_with_config( capsys: CaptureFixture[str], mocker: MockerFixture, + platform_mocker: Callable[[str, str], List[str]], parameterized_zuliprc: Callable[[Dict[str, str]], str], config_key: str, config_value: str, footlinks_output: str, platform: str = "some_platform", + python: str = "3.99 (Zython) [cool]", ) -> None: - mocker.patch(MODULE + ".detected_platform", return_value=platform) - config = { "theme": "default", "autohide": "autohide", @@ -393,22 +477,27 @@ def test_successful_main_function_with_config( } config[config_key] = config_value zuliprc = parameterized_zuliprc(config) + mocker.patch(CONTROLLER + ".__init__", return_value=None) mocker.patch(CONTROLLER + ".main", return_value=None) + expected_platform_output = platform_mocker(platform, python) + with pytest.raises(SystemExit): main(["-c", zuliprc]) captured = capsys.readouterr() lines = captured.out.strip().split("\n") - expected_lines = [ - f"Detected platform: {platform}", + expected_lines = expected_platform_output + [ "Loading with:", " theme 'zt_dark' specified in zuliprc file (by alias 'default').", " autohide setting 'autohide' specified in zuliprc file.", + " exit confirmation setting 'enabled' specified from default config.", f" maximum footlinks value {footlinks_output}", " color depth setting '256' specified in zuliprc file.", " notify setting 'enabled' specified in zuliprc file.", + " transparency setting 'disabled' specified from default config.", + " external editor command '' specified from environment.", ] assert lines == expected_lines @@ -431,16 +520,20 @@ def test_successful_main_function_with_config( def test_main_error_with_invalid_zuliprc_options( capsys: CaptureFixture[str], mocker: MockerFixture, + platform_mocker: Callable[[str, str], List[str]], parameterized_zuliprc: Callable[[Dict[str, str]], str], zulip_config: Dict[str, str], error_message: str, platform: str = "some_platform", + python: str = "3.99 (Zython) [cool]", ) -> None: zuliprc = parameterized_zuliprc(zulip_config) mocker.patch(CONTROLLER + ".__init__", return_value=None) mocker.patch(MODULE + ".detected_platform", return_value=platform) mocker.patch(CONTROLLER + ".main", return_value=None) + expected_platform_output = platform_mocker(platform, python) + with pytest.raises(SystemExit) as e: main(["-c", zuliprc]) @@ -449,7 +542,7 @@ def test_main_error_with_invalid_zuliprc_options( captured = capsys.readouterr() lines = captured.out.strip() expected_lines = "\n".join( - [f"Detected platform: {platform}", f"\033[91m{error_message}\033[0m"] + expected_platform_output + [f"\033[91m{error_message}\033[0m"] ) assert lines == expected_lines @@ -487,17 +580,29 @@ def test_exit_with_error( def test__write_zuliprc__success( tmp_path: Path, id: str = "id", key: str = "key", url: str = "url" ) -> None: - path = os.path.join(str(tmp_path), "zuliprc") - - error_message = _write_zuliprc(path, api_key=key, server_url=url, login_id=id) + """Test successful creation of zuliprc and zulip_key files.""" + path = tmp_path / "zuliprc" + key_path = tmp_path / "zulip_key" + + error_message = _write_zuliprc( + to_path=str(path), + key_path=str(key_path), + login_id=id, + api_key=key, + server_url=url, + ) assert error_message == "" - expected_contents = f"[api]\nemail={id}\nkey={key}\nsite={url}" + expected_contents = f"[api]\nemail={id}\npasscmd=cat zulip_key\nsite={url}" with open(path) as f: assert f.read() == expected_contents + with open(key_path) as f: + assert f.read() == key + assert stat.filemode(os.stat(path).st_mode)[-6:] == 6 * "-" + assert stat.filemode(os.stat(key_path).st_mode)[-6:] == 6 * "-" def test__write_zuliprc__fail_file_exists( @@ -507,11 +612,24 @@ def test__write_zuliprc__fail_file_exists( key: str = "key", url: str = "url", ) -> None: - path = os.path.join(str(tmp_path), "zuliprc") - - error_message = _write_zuliprc(path, api_key=key, server_url=url, login_id=id) + """Test that _write_zuliprc fails when files already exist.""" + path = tmp_path / "zuliprc" + key_path = tmp_path / "zulip_key" + + # Create the files first to simulate they already exist + with open(path, "w") as f: + f.write("existing content") + + error_message = _write_zuliprc( + to_path=str(path), + key_path=str(key_path), + login_id=id, + api_key=key, + server_url=url, + ) - assert error_message == "zuliprc already exists at " + path + assert error_message == f"FileExistsError: {path} already exists" + assert not Path(key_path).exists() # key_path should not be created @pytest.mark.parametrize( diff --git a/tests/config/test_keys.py b/tests/config/test_keys.py index c0807962d3..752e69af29 100644 --- a/tests/config/test_keys.py +++ b/tests/config/test_keys.py @@ -1,4 +1,4 @@ -from typing import Any, Dict +from typing import Any, Dict, List import pytest from pytest_mock import MockerFixture @@ -112,3 +112,52 @@ def test_updated_urwid_command_map() -> None: assert key in keys.keys_for_command(zt_cmd) except KeyError: pass + + +@pytest.mark.parametrize( + "urwid_key, display_key", + [ + ("a", "a"), + ("B", "B"), + (":", ":"), + ("enter", "Enter"), + ("meta c", "Meta c"), + ("ctrl D", "Ctrl D"), + ("page up", "PgUp"), + ("ctrl page up", "Ctrl PgUp"), + ], + ids=[ + "lowercase_alphabet_key", + "uppercase_alphabet_key", + "symbol_key", + "special_key", + "lowercase_alphabet_key_with_modifier_key", + "uppercase_alphabet_key_with_modifier_key", + "mapped_key", + "mapped_key_with_modifier_key", + ], +) +def test_display_key_for_urwid_key(urwid_key: str, display_key: str) -> None: + assert keys.display_key_for_urwid_key(urwid_key) == display_key + + +COMMAND_TO_DISPLAY_KEYS = [ + ("NEXT_LINE", ["Down", "Ctrl n"]), + ("TOGGLE_STAR_STATUS", ["Ctrl s", "*"]), + ("ALL_PM", ["P"]), +] + + +@pytest.mark.parametrize("command, display_keys", COMMAND_TO_DISPLAY_KEYS) +def test_display_keys_for_command(command: str, display_keys: List[str]) -> None: + assert keys.display_keys_for_command(command) == display_keys + + +@pytest.mark.parametrize("command, display_keys", COMMAND_TO_DISPLAY_KEYS) +def test_primary_display_key_for_command(command: str, display_keys: List[str]) -> None: + assert keys.primary_display_key_for_command(command) == display_keys[0] + + +def test_display_keys_for_command_invalid_command(invalid_command: str) -> None: + with pytest.raises(keys.InvalidCommand): + keys.display_keys_for_command(invalid_command) diff --git a/tests/config/test_themes.py b/tests/config/test_themes.py index 73ae9cd301..34c7e17839 100644 --- a/tests/config/test_themes.py +++ b/tests/config/test_themes.py @@ -1,27 +1,36 @@ import re -from copy import deepcopy from enum import Enum from typing import Any, Dict, Optional, Tuple import pytest +from pygments.styles.material import MaterialStyle from pygments.styles.perldoc import PerldocStyle +from pygments.token import STANDARD_TYPES, _TokenType +from pytest import param as case from pytest_mock import MockerFixture +from zulipterminal.config.color import Background from zulipterminal.config.regexes import REGEX_COLOR_VALID_FORMATS from zulipterminal.config.themes import ( REQUIRED_STYLES, + STYLE_TRANSLATIONS, THEMES, InvalidThemeColorCode, + MissingThemeAttributeError, ThemeSpec, - add_pygments_style, all_themes, complete_and_incomplete_themes, + generate_pygments_styles, + generate_theme, + generate_urwid_compatible_pygments_styles, parse_themefile, valid_16_color_codes, validate_colors, ) +MODULE = "zulipterminal.config.themes" + expected_complete_themes = { "zt_dark", "gruvbox_dark", @@ -78,7 +87,8 @@ def test_builtin_theme_completeness(theme_name: str) -> None: # Check if color used in STYLE exists in Color. for style_name, style_conf in theme_styles.items(): fg, bg = style_conf - assert fg in theme_colors and bg in theme_colors + assert fg in theme_colors + assert bg in theme_colors or bg in Background # Check completeness of META expected_META = {"pygments": ["styles", "background", "overrides"]} for metadata, config in expected_META.items(): @@ -86,7 +96,7 @@ def test_builtin_theme_completeness(theme_name: str) -> None: assert all(theme_meta[metadata][c] for c in config) -def test_complete_and_incomplete_themes() -> None: +def test_complete_and_incomplete_themes__bundled_theme_output() -> None: # These are sorted to ensure reproducibility result = ( sorted(expected_complete_themes), @@ -95,6 +105,162 @@ def test_complete_and_incomplete_themes() -> None: assert result == complete_and_incomplete_themes() +@pytest.mark.parametrize( + "missing, expected_complete", + [ + case({}, True, id="keys_complete"), + case({"Color": None}, False, id="Color_absent"), + case({"STYLES": None}, False, id="STYLES_absent"), + case({"STYLES": "incomplete_style"}, False, id="STYLES_incomplete"), + case({"META": None}, False, id="META_absent"), + case({"META": {}}, False, id="META_empty"), + case({"META": {"pygments": {}}}, False, id="META_pygments_empty"), + case({"META": "extra_field"}, True, id="META_with_extra_field"), + ], +) +def test_complete_and_incomplete_themes__single_theme_completeness( + mocker: MockerFixture, + missing: Dict[str, Any], + expected_complete: bool, + style: str = "s", + fake_theme_name: str = "sometheme", +) -> None: + class FakeColor(Enum): + COLOR_1 = "a a #" + COLOR_2 = "k b #" + + class FakeTheme: + Color = FakeColor + STYLES = { + style: (FakeColor.COLOR_1, FakeColor.COLOR_2) for style in REQUIRED_STYLES + } + META: Dict[str, Any] = { + "pygments": { + "styles": None, + "background": None, + "overrides": None, + } + } + + incomplete_style = {style: (FakeColor.COLOR_1, FakeColor.COLOR_2)} + + for field, action in missing.items(): + if action == "incomplete_style": + setattr(FakeTheme, field, incomplete_style) + elif action == "extra_field": + FakeTheme.META["extra_field"] = 5 # Non-iterable misc data + elif action is None: + delattr(FakeTheme, field) + else: + setattr(FakeTheme, field, action) + + mocker.patch(MODULE + ".THEMES", {fake_theme_name: FakeTheme}) + + if expected_complete: + assert complete_and_incomplete_themes() == ([fake_theme_name], []) + else: + assert complete_and_incomplete_themes() == ([], [fake_theme_name]) + + +@pytest.mark.parametrize( + "META, expected_pygments_length", + [ + case(None, 0, id="META_absent"), + case( + { + "pygments": { + "styles": MaterialStyle().styles, + "background": "h80", + "overrides": {}, + } + }, + len(STANDARD_TYPES), + id="META_with_valid_values", + ), + ], +) +def test_generate_theme__has_required_attributes( + mocker: MockerFixture, + META: Optional[Dict[str, Dict[str, Any]]], + expected_pygments_length: int, + fake_theme_name: str = "fake_theme", + depth: int = 256, # Only test one depth; others covered in parse_themefile tests + single_style: str = "somestyle", +) -> None: + class FakeColor(Enum): + COLOR_1 = "a a #" + COLOR_2 = "k b #" + + theme_styles = {single_style: (FakeColor.COLOR_1, FakeColor.COLOR_2)} + + class FakeTheme: + STYLES = theme_styles + Color = FakeColor # Required for validate_colors + + if META is not None: + FakeTheme.META = META # type: ignore [attr-defined] + + mocker.patch(MODULE + ".THEMES", {fake_theme_name: FakeTheme}) + + generated_theme = generate_theme( + fake_theme_name, color_depth=depth, transparent_background=False + ) + + assert len(generated_theme) == len(theme_styles) + expected_pygments_length + assert (single_style, "", "", "", "a", "b") in generated_theme + + +def test_generate_theme__missing_attributes_in_theme( + mocker: MockerFixture, + fake_theme_name: str = "fake_theme", + depth: int = 256, + style: str = "somestyle", +) -> None: + class FakeTheme: + pass + + mocker.patch(MODULE + ".THEMES", {fake_theme_name: FakeTheme}) + + kwargs: Dict[str, Any] = dict(color_depth=depth, transparent_background=False) + + # No attributes (STYLES or META) - flag missing Color + with pytest.raises(MissingThemeAttributeError) as e: + generate_theme(fake_theme_name, **kwargs) + assert str(e.value) == "Theme is missing required attribute 'Color'" + + # Color but missing STYLES - flag missing STYLES + class FakeColor(Enum): + COLOR_1 = "a a #" + COLOR_2 = "k b #" + + FakeTheme.Color = FakeColor # type: ignore [attr-defined] + + with pytest.raises(MissingThemeAttributeError) as e: + generate_theme(fake_theme_name, **kwargs) + assert str(e.value) == "Theme is missing required attribute 'STYLES'" + + # Color, STYLES and META, but no pygments data in META + not_all_styles = {style: (FakeColor.COLOR_1, FakeColor.COLOR_2)} + FakeTheme.STYLES = not_all_styles # type: ignore [attr-defined] + FakeTheme.META = {} # type: ignore [attr-defined] + + with pytest.raises(MissingThemeAttributeError) as e: + generate_theme(fake_theme_name, **kwargs) + assert str(e.value) == """Theme is missing required attribute 'META["pygments"]'""" + + # Color, STYLES and META, but incomplete pygments in META + FakeTheme.META = { # type: ignore [attr-defined] + "pygments": {"styles": "", "background": ""} + } + + with pytest.raises(MissingThemeAttributeError) as e: + generate_theme(fake_theme_name, **kwargs) + assert ( + str(e.value) + == """Theme is missing required attribute 'META["pygments"]["overrides"]'""" + ) + + @pytest.mark.parametrize( "color_depth, expected_urwid_theme", [ @@ -143,21 +309,22 @@ class Color(Enum): req_styles = {"s1": "", "s2": "bold"} mocker.patch.dict("zulipterminal.config.themes.REQUIRED_STYLES", req_styles) - assert parse_themefile(theme_styles, color_depth) == expected_urwid_theme + assert ( + parse_themefile(theme_styles, color_depth, Color.DARK_MAGENTA, False) + == expected_urwid_theme + ) @pytest.mark.parametrize( - "theme_meta, expected_styles", + "pygments_data, expected_styles", [ ( { - "pygments": { - "styles": PerldocStyle().styles, - "background": "#def", - "overrides": { - "k": "#abc", - "sd": "#123, bold", - }, + "styles": PerldocStyle().styles, + "background": "#def", + "overrides": { + "k": "#abc", + "sd": "#123, bold", }, }, [ @@ -182,36 +349,18 @@ class Color(Enum): ) ], ) -def test_add_pygments_style( - mocker: MockerFixture, theme_meta: Dict[str, Any], expected_styles: ThemeSpec +def test_generate_pygments_styles( + mocker: MockerFixture, pygments_data: Dict[str, Any], expected_styles: ThemeSpec ) -> None: - urwid_theme: ThemeSpec = [(None, "#xxx", "#yyy")] - original_urwid_theme = deepcopy(urwid_theme) - - add_pygments_style(theme_meta, urwid_theme) + pygments_styles = generate_pygments_styles(pygments_data) - # Check if original exists - assert original_urwid_theme[0] in urwid_theme # Check for overrides(k,sd) and inheriting styles (kr) for style in expected_styles: - assert style in urwid_theme + assert style in pygments_styles -# Validate 16-color-codes -@pytest.mark.parametrize( - "color_depth, theme_name", - [ - (16, "zt_dark"), - (16, "gruvbox_dark"), - (16, "gruvbox_light"), - (16, "zt_light"), - (16, "zt_blue"), - ], -) -def test_validate_colors(theme_name: str, color_depth: int) -> None: - theme = THEMES[theme_name] - - header_text = f"Invalid 16-color codes in theme '{theme_name}':\n" +def test_validate_colors(color_depth: int = 16) -> None: + header_text = "Invalid 16-color codes found in this theme:\n" # No invalid colors class Color(Enum): @@ -221,8 +370,7 @@ class Color(Enum): GRAY_244 = "dark_gray h244 #928374" LIGHT2 = "white h250 #d5c4a1" - theme.Color = Color - validate_colors(theme_name, 16) + validate_colors(Color, color_depth) # One invalid color class Color1(Enum): @@ -232,9 +380,8 @@ class Color1(Enum): GRAY_244 = "dark_gray h244 #928374" LIGHT2 = "white h250 #d5c4a1" - theme.Color = Color1 with pytest.raises(InvalidThemeColorCode) as e: - validate_colors(theme_name, 16) + validate_colors(Color1, color_depth) assert str(e.value) == header_text + "- DARK0_HARD = blac" # Two invalid colors @@ -245,9 +392,8 @@ class Color2(Enum): GRAY_244 = "dark_gra h244 #928374" LIGHT2 = "white h250 #d5c4a1" - theme.Color = Color2 with pytest.raises(InvalidThemeColorCode) as e: - validate_colors(theme_name, 16) + validate_colors(Color2, color_depth) assert ( str(e.value) == header_text + "- DARK0_HARD = blac\n" + "- GRAY_244 = dark_gra" ) @@ -260,9 +406,8 @@ class Color3(Enum): GRAY_244 = "dark_gra h244 #928374" LIGHT2 = "whit h250 #d5c4a1" - theme.Color = Color3 with pytest.raises(InvalidThemeColorCode) as e: - validate_colors(theme_name, 16) + validate_colors(Color3, color_depth) assert ( str(e.value) == header_text @@ -271,3 +416,65 @@ class Color3(Enum): + "- GRAY_244 = dark_gra\n" + "- LIGHT2 = whit" ) + + +@pytest.mark.parametrize( + "pygments_styles, expected_styles, style_translations", + [ + case( + {}, + {}, + STYLE_TRANSLATIONS, + id="empty_input", + ), + case( + { + "token1": "style1", + "token2": "style2", + }, + { + "token1": "style1", + "token2": "style2", + }, + {}, + id="empty_translations", + ), + case( + { + "token1": "bold italic", # pygments/pygments#2444 + "token2": "italic bold", # + order shouldn't matter + "token3": "italic #abc", # + italic should work with color + }, + { + "token1": "bold,italics", + "token2": "italics,bold", + "token3": "italics,#abc", + }, + STYLE_TRANSLATIONS, + id="default_translations", + ), + case( + { + "token1": "style italic", + "token2": "#abc", + }, + { + "token1": "newstyle italic", + "token2": "#abc", + }, + {"style": "newstyle"}, + id="custom_translations", + ), + ], +) +def test_generate_urwid_compatible_pygments_styles( + pygments_styles: Dict[_TokenType, str], # NOTE: placeholder string values used + expected_styles: Dict[_TokenType, str], # in parametrized dict keys + style_translations: Dict[str, str], +) -> None: + generated_styles = generate_urwid_compatible_pygments_styles( + pygments_styles, style_translations + ) + + assert set(expected_styles) == set(generated_styles) # keys unchanged + assert sorted(expected_styles.items()) == sorted(generated_styles.items()) diff --git a/tests/conftest.py b/tests/conftest.py index c4dc1c6532..ad92c4cc71 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,13 +6,23 @@ from pytest_mock import MockerFixture from urwid import Widget -from zulipterminal.api_types import Message +from zulipterminal.api_types import ( + CustomFieldValue, + CustomProfileField, + Message, + MessageType, +) from zulipterminal.config.keys import ( ZT_TO_URWID_CMD_MAPPING, keys_for_command, primary_key_for_command, ) -from zulipterminal.helper import Index, TidiedUserInfo +from zulipterminal.helper import ( + CustomProfileData, + Index, + MinimalUserData, + TidiedUserInfo, +) from zulipterminal.helper import initial_index as helper_initial_index from zulipterminal.ui_tools.buttons import StreamButton, TopicButton, UserButton from zulipterminal.ui_tools.messages import MessageBox @@ -109,9 +119,11 @@ def msg_box( """ Mocked MessageBox with stream message """ + model_mock = mocker.patch("zulipterminal.model.Model") + model_mock.stream_access_type.return_value = "public" return MessageBox( messages_successful_response["messages"][0], - mocker.patch("zulipterminal.model.Model"), + model_mock, None, ) @@ -120,7 +132,10 @@ def msg_box( @pytest.fixture -def users_fixture(logged_on_user: Dict[str, Any]) -> List[Dict[str, Any]]: +def users_fixture( + logged_on_user: Dict[str, Any], + custom_profile_data_fixture: Dict[str, CustomFieldValue], +) -> List[Dict[str, Any]]: users = [logged_on_user] for i in range(1, 3): users.append( @@ -149,11 +164,19 @@ def users_fixture(logged_on_user: Dict[str, Any]) -> List[Dict[str, Any]]: "is_admin": False, } ) + # Add custom profile data to user 12 + for user in users: + if user["user_id"] == 12: + user["profile_data"] = custom_profile_data_fixture + else: + user["profile_data"] = {} return users @pytest.fixture -def tidied_user_info_response() -> TidiedUserInfo: +def tidied_user_info_response( + clean_custom_profile_data_fixture: List[CustomProfileData], +) -> TidiedUserInfo: # FIXME: Refactor this to use a more generic user? return { "full_name": "Human 2", @@ -165,6 +188,7 @@ def tidied_user_info_response() -> TidiedUserInfo: "bot_type": None, "bot_owner_name": "", "last_active": "", + "custom_profile_data": clean_custom_profile_data_fixture, } @@ -416,7 +440,7 @@ def display_recipient_factory( def msg_template_factory( msg_id: int, - msg_type: str, + msg_type: MessageType, timestamp: int, *, subject: str = "", @@ -535,7 +559,7 @@ def messages_successful_response( params=SUPPORTED_SERVER_VERSIONS, ids=(lambda param: "server_version:{}-server_feature_level:{}".format(*param)), ) -def zulip_version(request: Any) -> Tuple[str, Optional[int]]: +def zulip_version(request: Any) -> Tuple[str, int]: """ Fixture to test different components based on the server version and the feature level. @@ -587,7 +611,14 @@ def message_history(request: Any) -> List[Dict[str, Any]]: @pytest.fixture def topics() -> List[str]: - return ["Topic 1", "This is a topic", "Hello there!"] + return [ + "Topic 1", + "This is a topic", + "Hello there!", + "He-llo there!", + "Hello t/here!", + "Hello from out-er_space!", + ] @pytest.fixture( @@ -619,12 +650,231 @@ def mentioned_messages_combination(request: Any) -> Tuple[Set[int], Set[int]]: return deepcopy(request.param) +@pytest.fixture +def custom_profile_fields_fixture() -> List[CustomProfileField]: + return [ + { + # Short text: For one line responses, like "Job title". + # Responses are limited to 50 characters. + "id": 1, + "name": "Phone number", + "type": 1, + "hint": "", + "field_data": "", + "order": 1, + }, + { + # Long text: For multiline responses, like "Biography". + "id": 2, + "name": "Biography", + "type": 2, + "hint": "What are you known for?", + "field_data": "", + "order": 2, + }, + { + # Another example of Short text field. + "id": 3, + "name": "Favorite food", + "type": 1, + "hint": "Or drink, if you'd prefer", + "field_data": "", + "order": 3, + }, + { + # List of options: Creates a dropdown with a list of options. + "id": 4, + "name": "Favorite editor", + "type": 3, + "hint": "", + "field_data": ( + '{"0":{"text":"Vim","order":"1"},"1":{"text":"Emacs","order":"2"}}' + ), + "order": 4, + }, + { + # Date picker: For dates, like "Birthday". + "id": 5, + "name": "Birthday", + "type": 4, + "hint": "", + "field_data": "", + "order": 5, + }, + { + # Link: For links to websites. + "id": 6, + "name": "Favorite website", + "type": 5, + "hint": "Or your personal blog's URL", + "field_data": "", + "order": 6, + }, + { + # Person picker: For selecting one or more users, + # like "Manager" or "Direct reports". + "id": 7, + "name": "Manager", + "type": 6, + "hint": "Only one person", + "field_data": "", + "order": 7, + }, + { + # Another person picker: Use case with multiple users. + "id": 8, + "name": "Mentor", + "type": 6, + "hint": "", + "field_data": "", + "order": 8, + }, + { + # External account: For linking to GitHub, Twitter, etc. + # GitHub example + "id": 9, + "name": "GitHub username", + "type": 7, + "hint": "", + "field_data": '{"subtype":"github"}', + "order": 9, + }, + { + # Twitter example + "id": 10, + "name": "Twitter username", + "type": 7, + "hint": "", + "field_data": '{"subtype":"twitter"}', + "order": 10, + }, + { + # Custom example + "id": 11, + "name": "Reddit username", + "type": 7, + "hint": "", + "field_data": '{"subtype":"custom", "url_pattern":"https://www.reddit.com/u/%(username)s"}', + "order": 11, + }, + { + # Pronouns: What pronouns should people use to refer to the user? + "id": 12, + "name": "Pronouns", + "type": 8, + "hint": "What pronouns should people use to refer to you?", + "field_data": "", + "order": 12, + }, + ] + + +@pytest.fixture +def custom_profile_data_fixture() -> Dict[str, CustomFieldValue]: + return { + "1": {"value": "6352813452", "rendered_value": "

6352813452

"}, + "2": { + "value": "Simplicity\nThis is a multiline field", + "rendered_value": "

Simplicity\nThis is a multiline field

", + }, + "3": {"value": "cola", "rendered_value": "

cola

"}, + "4": {"value": "0"}, + "5": {"value": "2023-04-22"}, + "6": {"value": "https://www.google.com"}, + "7": {"value": "[11]"}, + "8": {"value": "[11, 13]"}, + "9": {"value": "gitmaster"}, + "10": {"value": "twittermaster"}, + "11": {"value": "redditmaster"}, + "12": {"value": "he/him"}, + } + + +@pytest.fixture +def clean_custom_profile_data_fixture() -> List[CustomProfileData]: + return [ + { + "label": "Phone number", + "value": "6352813452", + "type": 1, + "order": 1, + }, + { + "label": "Biography", + "value": "Simplicity\nThis is a multiline field", + "type": 2, + "order": 2, + }, + { + "label": "Favorite food", + "value": "cola", + "type": 1, + "order": 3, + }, + { + "label": "Favorite editor", + "value": "Vim", + "type": 3, + "order": 4, + }, + { + "label": "Birthday", + "value": "2023-04-22", + "type": 4, + "order": 5, + }, + { + "label": "Favorite website", + "value": "https://www.google.com", + "type": 5, + "order": 6, + }, + { + "label": "Manager", + "value": [11], + "type": 6, + "order": 7, + }, + { + "label": "Mentor", + "value": [11, 13], + "type": 6, + "order": 8, + }, + { + "label": "GitHub username", + "value": "https://github.com/gitmaster", + "type": 7, + "order": 9, + }, + { + "label": "Twitter username", + "value": "https://twitter.com/twittermaster", + "type": 7, + "order": 10, + }, + { + "label": "Reddit username", + "value": "https://www.reddit.com/u/redditmaster", + "type": 7, + "order": 11, + }, + { + "label": "Pronouns", + "value": "he/him", + "type": 8, + "order": 12, + }, + ] + + @pytest.fixture def initial_data( logged_on_user: Dict[str, Any], users_fixture: List[Dict[str, Any]], streams_fixture: List[Dict[str, Any]], realm_emojis: Dict[str, Dict[str, Any]], + custom_profile_fields_fixture: List[Dict[str, Union[str, int]]], ) -> Dict[str, Any]: """ Response from /register API request. @@ -799,6 +1049,7 @@ def initial_data( "zulip_version": MINIMUM_SUPPORTED_SERVER_VERSION[0], "zulip_feature_level": MINIMUM_SUPPORTED_SERVER_VERSION[1], "starred_messages": [1117554, 1117558, 1117574], + "custom_profile_fields": custom_profile_fields_fixture, } @@ -813,7 +1064,7 @@ def empty_index( ) -> Index: return deepcopy( Index( - pointer=defaultdict(set, {}), + pointer=dict(), all_msg_ids=set(), starred_msg_ids=set(), mentioned_msg_ids=set(), @@ -983,7 +1234,7 @@ def error_response() -> Dict[str, str]: @pytest.fixture -def user_dict(logged_on_user: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: +def user_dict(logged_on_user: Dict[str, Any]) -> Dict[str, MinimalUserData]: """ User_dict created according to `initial_data` fixture. """ @@ -1021,25 +1272,25 @@ def user_dict(logged_on_user: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: "emailgateway@zulip.com": { "email": "emailgateway@zulip.com", "full_name": "Email Gateway", - "status": "inactive", + "status": "bot", "user_id": 6, }, "feedback@zulip.com": { "email": "feedback@zulip.com", "full_name": "Zulip Feedback Bot", - "status": "inactive", + "status": "bot", "user_id": 1, }, "notification-bot@zulip.com": { "email": "notification-bot@zulip.com", "full_name": "Notification Bot", - "status": "inactive", + "status": "bot", "user_id": 5, }, "welcome-bot@zulip.com": { "email": "welcome-bot@zulip.com", "full_name": "Welcome Bot", - "status": "inactive", + "status": "bot", "user_id": 4, }, } @@ -1075,12 +1326,6 @@ def user_list(logged_on_user: Dict[str, Any]) -> List[Dict[str, Any]]: "status": "active", "user_id": logged_on_user["user_id"], }, - { - "email": "emailgateway@zulip.com", - "full_name": "Email Gateway", - "status": "inactive", - "user_id": 6, - }, { "full_name": "Human 1", "email": "person1@example.com", @@ -1105,22 +1350,28 @@ def user_list(logged_on_user: Dict[str, Any]) -> List[Dict[str, Any]]: "user_id": 14, "status": "inactive", }, + { + "email": "emailgateway@zulip.com", + "full_name": "Email Gateway", + "status": "bot", + "user_id": 6, + }, { "email": "notification-bot@zulip.com", "full_name": "Notification Bot", - "status": "inactive", + "status": "bot", "user_id": 5, }, { "email": "welcome-bot@zulip.com", "full_name": "Welcome Bot", - "status": "inactive", + "status": "bot", "user_id": 4, }, { "email": "feedback@zulip.com", "full_name": "Zulip Feedback Bot", - "status": "inactive", + "status": "bot", "user_id": 1, }, ] @@ -1198,7 +1449,7 @@ def stream_dict(streams_fixture: List[Dict[str, Any]]) -> Dict[int, Any]: }, ], ids=[ - "zulip_feature_level:None", + "zulip_feature_level:0", "zulip_feature_level:1", ], ) diff --git a/tests/core/test_core.py b/tests/core/test_core.py index 93f005c905..ff6701a41d 100644 --- a/tests/core/test_core.py +++ b/tests/core/test_core.py @@ -44,10 +44,14 @@ def controller(self, mocker: MockerFixture) -> Controller: self.config_file = "path/to/zuliprc" self.theme_name = "zt_dark" - self.theme = generate_theme("zt_dark", 256) + self.theme = generate_theme( + "zt_dark", color_depth=256, transparent_background=False + ) self.in_explore_mode = False self.autohide = True # FIXME Add tests for no-autohide self.notify_enabled = False + self.exit_confirmation = True + self.transparency_enabled = False self.maximum_footlinks = 3 result = Controller( config_file=self.config_file, @@ -57,9 +61,12 @@ def controller(self, mocker: MockerFixture) -> Controller: color_depth=256, in_explore_mode=self.in_explore_mode, debug_path=None, + editor_command="", **dict( autohide=self.autohide, notify=self.notify_enabled, + exit_confirmation=self.exit_confirmation, + transparency=self.transparency_enabled, ), ) result.view.message_view = mocker.Mock() # set in View.__init__ @@ -222,13 +229,20 @@ def test_narrow_to_user( controller.view.message_view = mocker.patch("urwid.ListBox") controller.model.user_id = 5140 controller.model.user_email = "some@email" - controller.model.user_dict = {user_email: {"user_id": user_id}} + controller.model.user_dict = { + user_email: { + "user_id": user_id, + "full_name": "", + "email": "", + "status": "active", + } + } emails = [user_email] controller.narrow_to_user(recipient_emails=emails) - assert controller.model.narrow == [["pm_with", user_email]] + assert controller.model.narrow == [["pm-with", user_email]] controller.view.message_view.log.clear.assert_called_once_with() recipients = frozenset([controller.model.user_id, user_id]) assert controller.model.recipients == recipients @@ -397,15 +411,24 @@ def test_copy_to_clipboard_exception( assert popup.call_args_list[0][0][1] == "area:error" @pytest.mark.parametrize( - "url", + "url, webbrowser_name, expected_webbrowser_name", [ - "https://chat.zulip.org/#narrow/stream/test", - "https://chat.zulip.org/user_uploads/sent/abcd/efg.png", - "https://github.com/", + ("https://chat.zulip.org/#narrow/stream/test", "chrome", "chrome"), + ( + "https://chat.zulip.org/user_uploads/sent/abcd/efg.png", + "mozilla", + "mozilla", + ), + ("https://github.com/", None, "default browser"), ], ) def test_open_in_browser_success( - self, mocker: MockerFixture, controller: Controller, url: str + self, + mocker: MockerFixture, + controller: Controller, + url: str, + webbrowser_name: Optional[str], + expected_webbrowser_name: str, ) -> None: # Set DISPLAY environ to be able to run test in CI os.environ["DISPLAY"] = ":0" @@ -413,11 +436,16 @@ def test_open_in_browser_success( mock_get = mocker.patch(MODULE + ".webbrowser.get") mock_open = mock_get.return_value.open + if webbrowser_name is None: + del mock_get.return_value.name + else: + mock_get.return_value.name = webbrowser_name + controller.open_in_browser(url) mock_open.assert_called_once_with(url) mocked_report_success.assert_called_once_with( - [f"The link was successfully opened using {mock_get.return_value.name}"] + [f"The link was successfully opened using {expected_webbrowser_name}"] ) def test_open_in_browser_fail__no_browser_controller( @@ -474,8 +502,8 @@ def test_stream_muting_confirmation_popup( ([["search", "BOO"]], [["search", "FOO"]]), ([["stream", "PTEST"]], [["stream", "PTEST"], ["search", "FOO"]]), ( - [["pm_with", "foo@zulip.com"], ["search", "BOO"]], - [["pm_with", "foo@zulip.com"], ["search", "FOO"]], + [["pm-with", "foo@zulip.com"], ["search", "BOO"]], + [["pm-with", "foo@zulip.com"], ["search", "FOO"]], ), ( [["stream", "PTEST"], ["topic", "RDS"]], diff --git a/tests/helper/test_helper.py b/tests/helper/test_helper.py index e32b9963fb..2e32e5c10f 100644 --- a/tests/helper/test_helper.py +++ b/tests/helper/test_helper.py @@ -5,7 +5,7 @@ from pytest_mock import MockerFixture from zulipterminal.api_types import Composition -from zulipterminal.config.keys import primary_key_for_command +from zulipterminal.config.keys import primary_display_key_for_command from zulipterminal.helper import ( Index, canonicalize_color, @@ -19,6 +19,7 @@ open_media, powerset, process_media, + sort_unread_topics, ) @@ -79,7 +80,7 @@ def test_index_messages_narrow_user( messages = messages_successful_response["messages"] model = mocker.patch(MODEL + ".__init__", return_value=None) model.index = initial_index - model.narrow = [["pm_with", "boo@zulip.com"]] + model.narrow = [["pm-with", "boo@zulip.com"]] model.is_search_narrow.return_value = False model.user_id = 5140 model.user_dict = { @@ -99,7 +100,7 @@ def test_index_messages_narrow_user_multiple( messages = messages_successful_response["messages"] model = mocker.patch(MODEL + ".__init__", return_value=None) model.index = initial_index - model.narrow = [["pm_with", "boo@zulip.com, bar@zulip.com"]] + model.narrow = [["pm-with", "boo@zulip.com, bar@zulip.com"]] model.is_search_narrow.return_value = False model.user_id = 5140 model.user_dict = { @@ -244,6 +245,41 @@ def test_powerset( assert powerset(iterable, map_func) == expected_powerset +@pytest.mark.parametrize( + "unread_topics, expected_value", + [ + case({}, [], id="no_unread_topics"), + case( + {(99, "topic1"): 1}, + [(99, "topic1")], + id="single_unread_topic", + ), + case( + {(999, "topic3"): 1, (1000, "topic2"): 1, (1, "topic1"): 1}, + [(1000, "topic2"), (1, "topic1"), (999, "topic3")], + id="multiple_unread_topics", + ), + case( + { + (999, "topic3"): 1, + (1000, "topic2"): 1, + (1000, "topic4"): 3, + (1, "topic1"): 1, + }, + [(1000, "topic2"), (1000, "topic4"), (1, "topic1"), (999, "topic3")], + id="multiple_unread_topics_in_same_stream", + ), + ], +) +def test_sort_unread_topics( + unread_topics: Dict[Tuple[int, str], int], + expected_value: List[Tuple[int, str]], + streams: List[Dict[str, Any]], +) -> None: + stream_list = [stream["id"] for stream in streams] + assert sort_unread_topics(unread_topics, stream_list) == expected_value + + @pytest.mark.parametrize( "muted_streams, muted_topics, vary_in_unreads", [ @@ -348,13 +384,13 @@ def test_display_error_if_present( ), case( {"type": "private", "to": [4, 5], "content": "Hi"}, - [["pm_with", "welcome-bot@zulip.com, notification-bot@zulip.com"]], + [["pm-with", "welcome-bot@zulip.com, notification-bot@zulip.com"]], False, id="group_private_conv__same_group_pm__not_notified", ), case( {"type": "private", "to": [4, 5], "content": "Hi"}, - [["pm_with", "welcome-bot@zulip.com"]], + [["pm-with", "welcome-bot@zulip.com"]], True, id="private_conv__other_pm__notified", ), @@ -362,7 +398,7 @@ def test_display_error_if_present( {"type": "private", "to": [4], "content": ":party_parrot:"}, [ [ - "pm_with", + "pm-with", "person1@example.com, person2@example.com, " "welcome-bot@zulip.com", ] @@ -433,7 +469,7 @@ def test_notify_if_message_sent_outside_narrow( notify_if_message_sent_outside_narrow(req, controller) if footer_updated: - key = primary_key_for_command("NARROW_MESSAGE_RECIPIENT") + key = primary_display_key_for_command("NARROW_MESSAGE_RECIPIENT") report_success.assert_called_once_with( [ "Message is sent outside of current narrow." diff --git a/tests/model/test_model.py b/tests/model/test_model.py index 04a553afcd..81bb57486e 100644 --- a/tests/model/test_model.py +++ b/tests/model/test_model.py @@ -1,10 +1,12 @@ +import copy import json from collections import OrderedDict from copy import deepcopy -from typing import Any, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple import pytest from pytest import param as case +from pytest_mock import MockerFixture from zulip import Client, ZulipError from zulipterminal.config.symbols import STREAM_TOPIC_SEPARATOR @@ -13,6 +15,11 @@ MAX_MESSAGE_LENGTH, MAX_STREAM_NAME_LENGTH, MAX_TOPIC_NAME_LENGTH, + PRESENCE_OFFLINE_THRESHOLD_SECS, + PRESENCE_PING_INTERVAL_SECS, + TYPING_STARTED_EXPIRY_PERIOD, + TYPING_STARTED_WAIT_PERIOD, + TYPING_STOPPED_WAIT_PERIOD, Model, ServerConnectionFailure, UserSettings, @@ -43,7 +50,7 @@ def mock_external_classes(self, mocker: Any) -> None: def model(self, mocker, initial_data, user_profile, unicode_emojis): mocker.patch(MODEL + ".get_messages", return_value="") self.client.register.return_value = initial_data - mocker.patch(MODEL + ".get_all_users", return_value=[]) + mocker.patch(MODEL + "._update_users_data_from_initial_data") # NOTE: PATCH WHERE USED NOT WHERE DEFINED self.classify_unread_counts = mocker.patch( MODULE + ".classify_unread_counts", return_value=[] @@ -71,19 +78,19 @@ def test_init( assert model.stream_dict == stream_dict assert model.recipients == frozenset() assert model.index == initial_index - assert model._last_unread_topic is None + assert model.last_unread_pm is None model.get_messages.assert_called_once_with( num_before=30, num_after=10, anchor=None ) assert model.initial_data == initial_data assert model.server_version == initial_data["zulip_version"] - assert model.server_feature_level == initial_data.get("zulip_feature_level") + assert model.server_feature_level == initial_data.get("zulip_feature_level", 0) assert model.user_id == user_profile["user_id"] assert model.user_full_name == user_profile["full_name"] assert model.user_email == user_profile["email"] assert model.server_name == initial_data["realm_name"] # FIXME Add test here for model.server_url - model.get_all_users.assert_called_once_with() + model._update_users_data_from_initial_data.assert_called_once_with() assert model.users == [] self.classify_unread_counts.assert_called_once_with(model) assert model.unread_counts == [] @@ -153,7 +160,7 @@ def test_user_settings_expected_contents(self, model): ( [["Stream 1", "muted stream muted topic"]], {("Stream 1", "muted stream muted topic"): None}, - None, + 0, ), ( [["Stream 2", "muted topic", 1530129122]], @@ -162,7 +169,7 @@ def test_user_settings_expected_contents(self, model): ), ], ids=[ - "zulip_feature_level:None", + "zulip_feature_level:0", "zulip_feature_level:1", ], ) @@ -190,7 +197,7 @@ def test_init_InvalidAPIKey_response(self, mocker, initial_data): MODEL + "._register_desired_events", return_value="Invalid API key" ) - mocker.patch(MODEL + ".get_all_users", return_value=[]) + mocker.patch(MODEL + "._update_users_data_from_initial_data") mocker.patch(MODEL + "._subscribe_to_streams") self.classify_unread_counts = mocker.patch( MODULE + ".classify_unread_counts", return_value=[] @@ -208,7 +215,7 @@ def test_init_ZulipError_exception(self, mocker, initial_data, exception_text="X MODEL + "._register_desired_events", side_effect=ZulipError(exception_text) ) - mocker.patch(MODEL + ".get_all_users", return_value=[]) + mocker.patch(MODEL + "._update_users_data_from_initial_data") mocker.patch(MODEL + "._subscribe_to_streams") self.classify_unread_counts = mocker.patch( MODULE + ".classify_unread_counts", return_value=[] @@ -221,7 +228,7 @@ def test_init_ZulipError_exception(self, mocker, initial_data, exception_text="X def test_register_initial_desired_events(self, mocker, initial_data): mocker.patch(MODEL + ".get_messages", return_value="") - mocker.patch(MODEL + ".get_all_users") + mocker.patch(MODEL + "._update_users_data_from_initial_data") self.client.register.return_value = initial_data model = Model(self.controller) @@ -230,6 +237,7 @@ def test_register_initial_desired_events(self, mocker, initial_data): "message", "update_message", "reaction", + "submessage", "subscription", "typing", "update_message_flags", @@ -253,6 +261,7 @@ def test_register_initial_desired_events(self, mocker, initial_data): "update_display_settings", "user_settings", "realm_emoji", + "custom_profile_fields", "zulip_version", ] model.client.register.assert_called_once_with( @@ -271,12 +280,19 @@ def test_register_initial_desired_events(self, mocker, initial_data): "expect_msg_retention_text", ], [ + case( + {1: {}}, + None, + 0, + {1: "Indefinite [Organization default]"}, + id="ZFL=0_no_stream_retention_realm_retention=None", + ), case( {1: {}}, None, 10, {1: "Indefinite [Organization default]"}, - id="ZFL=None_no_stream_retention_realm_retention=None", + id="ZFL=10_no_stream_retention_realm_retention=None", ), case( {1: {}, 2: {}}, @@ -330,15 +346,15 @@ def test_normalize_and_cache_message_retention_text( == expect_msg_retention_text[stream_id] ) - @pytest.mark.parametrize("msg_id", [1, 5, set()]) + @pytest.mark.parametrize("msg_id", [1, 5, None]) @pytest.mark.parametrize( "narrow", [ [], [["stream", "hello world"]], [["stream", "hello world"], ["topic", "what's it all about?"]], - [["pm_with", "FOO@zulip.com"]], - [["pm_with", "Foo@zulip.com, Bar@zulip.com"]], + [["pm-with", "FOO@zulip.com"]], + [["pm-with", "Foo@zulip.com, Bar@zulip.com"]], [["is", "private"]], [["is", "starred"]], ], @@ -355,16 +371,14 @@ def test_get_focus_in_current_narrow_individually(self, model, msg_id, narrow): [], [["stream", "hello world"]], [["stream", "hello world"], ["topic", "what's it all about?"]], - [["pm_with", "FOO@zulip.com"]], - [["pm_with", "Foo@zulip.com, Bar@zulip.com"]], + [["pm-with", "FOO@zulip.com"]], + [["pm-with", "Foo@zulip.com, Bar@zulip.com"]], [["is", "private"]], [["is", "starred"]], ], ) def test_set_focus_in_current_narrow(self, mocker, model, narrow, msg_id): - from collections import defaultdict - - model.index = dict(pointer=defaultdict(set)) + model.index = dict(pointer=dict()) model.narrow = narrow model.set_focus_in_current_narrow(msg_id) assert model.index["pointer"][str(narrow)] == msg_id @@ -414,7 +428,7 @@ def test_set_narrow_bad_input(self, model, bad_args): ([["is", "starred"]], dict(starred=True)), ([["is", "mentioned"]], dict(mentioned=True)), ([["is", "private"]], dict(pms=True)), - ([["pm_with", "FOO@zulip.com"]], dict(pm_with="FOO@zulip.com")), + ([["pm-with", "FOO@zulip.com"]], dict(pm_with="FOO@zulip.com")), ], ) def test_set_narrow_already_set(self, model, narrow, good_args): @@ -435,7 +449,7 @@ def test_set_narrow_already_set(self, model, narrow, good_args): ([], [["is", "starred"]], dict(starred=True)), ([], [["is", "mentioned"]], dict(mentioned=True)), ([], [["is", "private"]], dict(pms=True)), - ([], [["pm_with", "FOOBOO@gmail.com"]], dict(pm_with="FOOBOO@gmail.com")), + ([], [["pm-with", "FOOBOO@gmail.com"]], dict(pm_with="FOOBOO@gmail.com")), ], ) def test_set_narrow_not_already_set( @@ -466,12 +480,12 @@ def test_set_narrow_not_already_set( ), ([["is", "private"]], {"private_msg_ids": {0, 1}}, {0, 1}), ( - [["pm_with", "FOO@zulip.com"]], + [["pm-with", "FOO@zulip.com"]], {"private_msg_ids_by_user_ids": {frozenset({1, 2}): {0, 1}}}, {0, 1}, ), ( - [["pm_with", "FOO@zulip.com"]], + [["pm-with", "FOO@zulip.com"]], { # Covers recipient empty-set case "private_msg_ids_by_user_ids": { frozenset({1, 3}): {0, 1} # NOTE {1,3} not {1,2} @@ -551,6 +565,76 @@ def test_topics_in_stream(self, mocker, model, topics_index, fetched, stream_id= assert model.index["topics"][stream_id] == return_value assert model.index["topics"][stream_id] is not return_value + @pytest.mark.parametrize( + "response, expected_return_value", + [ + ( + {"result": "success", "msg": "", "email": "username@example.com"}, + "username@example.com", + ), + ( + {"result": "error", "msg": "Invalid stream ID", "code": "BAD_REQUEST"}, + None, + ), + ], + ids=["valid_email_returned", "no_email_returned"], + ) + def test__fetch_stream_email_from_endpoint( + self, + mocker: MockerFixture, + model: Any, + response: Dict[str, str], + expected_return_value: Optional[str], + stream_id: int = 1, + ) -> None: + self.client.call_endpoint = mocker.Mock(return_value=response) + + result = model._fetch_stream_email_from_endpoint(stream_id) + + self.client.call_endpoint.assert_called_once_with( + f"/streams/{stream_id}/email_address", method="GET" + ) + assert result == expected_return_value + + @pytest.mark.parametrize( + "email_address_stream_dict, email_fetch_response, " + "expected_fetched, expected_return_value", + [ + ( + {"email_address": "username@example.com"}, + None, + False, + "username@example.com", + ), + ({}, "username@example.com", True, "username@example.com"), + ({}, None, True, None), + ], + ids=[ + "email_present_in_dict:ZFL<226", + "email_absent_from_dict_and_fetched_ok:ZFL>=226", + "email_absent_from_dict_and_fetch_failed:ZFL>=226", + ], + ) + def test_get_stream_email_address( + self, + mocker: MockerFixture, + model: Any, + email_address_stream_dict: Dict[str, str], + email_fetch_response: Optional[str], + expected_fetched: bool, + expected_return_value: Optional[str], + stream_id: int = 1, + ) -> None: + model.stream_dict[stream_id] = email_address_stream_dict + model._fetch_stream_email_from_endpoint = mocker.Mock( + return_value=email_fetch_response + ) + + result = model.get_stream_email_address(stream_id) + + assert model._fetch_stream_email_from_endpoint.called == expected_fetched + assert result == expected_return_value + # pre server v3 provide user_id or id as a property within user key # post server v3 provide user_id as a property outside the user key @pytest.mark.parametrize("user_key", ["user_id", "id", None]) @@ -706,6 +790,75 @@ def test_has_user_reacted_to_message( assert has_reacted == expected_has_user_reacted + @pytest.mark.parametrize( + "user_data, expected_user_id", + [ + case({"user_id": 456}, 456, id="reaction_event_user_id"), + case( + {"user": {"user_id": 123}}, + 123, + id="reaction_event_user", + ), + case( + {"user_id": 101}, + 101, + id="reaction_user_id", + ), + case( + {"user": {"id": 202}}, + 202, + id="reaction_user", + ), + ], + ) + def test_get_user_id_from_reaction_success( + self, + model: Model, + user_data: Dict[str, Any], + expected_user_id: int, + ): + reaction = { + "type": "reaction", + "op": "add", + "reaction_type": "unicode_emoji", + "emoji_code": "1f44d", + "emoji_name": "thumbs_up", + "message_id": "24757", + **user_data, + } + assert model.get_user_id_from_reaction(reaction) == expected_user_id + + @pytest.mark.parametrize( + "user_data", + [ + case({}, id="reaction_event_missing_user_id_user"), + case( + {"user": {"id": "not_an_int"}}, + id="invalid_user", + ), + case( + {"user_id": "not_an_int"}, + id="invalid_user_id", + ), + ], + ) + def test_get_user_id_from_reaction_failure( + self, + model: Model, + user_data: Dict[str, Any], + ): + reaction = { + "type": "reaction", + "op": "add", + "reaction_type": "unicode_emoji", + "emoji_code": "1f44d", + "emoji_name": "thumbs_up", + "message_id": "24757", + **user_data, + } + with pytest.raises((AssertionError, AttributeError)): + model.get_user_id_from_reaction(reaction) + @pytest.mark.parametrize("recipient_user_ids", [[5140], [5140, 5179]]) @pytest.mark.parametrize("status", ["start", "stop"]) def test_send_typing_status_by_user_ids( @@ -761,7 +914,7 @@ def test_send_private_message( result = model.send_private_message(recipients, content) - req = dict(type="private", to=recipients, content=content) + req = dict(type="private", to=recipients, content=content, read_by_sender=True) self.client.send_message.assert_called_once_with(req) assert result == return_value @@ -798,7 +951,13 @@ def test_send_stream_message( result = model.send_stream_message(stream, topic, content) - req = dict(type="stream", to=stream, subject=topic, content=content) + req = dict( + type="stream", + to=stream, + subject=topic, + content=content, + read_by_sender=True, + ) self.client.send_message.assert_called_once_with(req) assert result == return_value @@ -970,7 +1129,7 @@ def test_update_stream_message( @pytest.mark.parametrize( "ZFL, expect_API_notify_args", [ - (None, False), + (0, False), (8, False), (9, True), (152, True), @@ -1282,7 +1441,7 @@ def test_success_get_messages( num_after=10, ): self.client.register.return_value = initial_data - mocker.patch(MODEL + ".get_all_users", return_value=[]) + mocker.patch(MODEL + "._update_users_data_from_initial_data") mocker.patch(MODEL + "._subscribe_to_streams") self.classify_unread_counts = mocker.patch( MODULE + ".classify_unread_counts", return_value=[] @@ -1348,7 +1507,7 @@ def test_modernize_message_response( @pytest.mark.parametrize( "feature_level, to_vary_in_initial_data", [ - (None, {}), + (0, {}), (27, {}), (52, {}), ( @@ -1361,7 +1520,7 @@ def test_modernize_message_response( ), ], ids=[ - "Zulip_2.1.x_ZFL_None_no_restrictions", + "Zulip_2.1.x_ZFL_0_no_restrictions", "Zulip_3.1.x_ZFL_27_no_restrictions", "Zulip_4.0.x_ZFL_52_no_restrictions", "Zulip_4.0.x_ZFL_53_with_restrictions", @@ -1391,6 +1550,99 @@ def test__store_content_length_restrictions( assert model.max_topic_length == MAX_TOPIC_NAME_LENGTH assert model.max_message_length == MAX_MESSAGE_LENGTH + def test__store_typing_duration_settings__default_values(self, model, initial_data): + model.initial_data = initial_data + + model._store_typing_duration_settings() + + assert model.typing_started_wait_period == TYPING_STARTED_WAIT_PERIOD + assert model.typing_stopped_wait_period == TYPING_STOPPED_WAIT_PERIOD + assert model.typing_started_expiry_period == TYPING_STARTED_EXPIRY_PERIOD + + def test__store_typing_duration_settings__with_values( + self, + model, + initial_data, + feature_level=204, + typing_started_wait=7500, + typing_stopped_wait=3000, + typing_started_expiry=10000, + ): + # Ensure inputs are not the defaults, to avoid the test accidentally passing + assert typing_started_wait != TYPING_STARTED_WAIT_PERIOD + assert typing_stopped_wait != TYPING_STOPPED_WAIT_PERIOD + assert typing_started_expiry != TYPING_STARTED_EXPIRY_PERIOD + + to_vary_in_initial_data = { + "server_typing_started_wait_period_milliseconds": typing_started_wait, + "server_typing_stopped_wait_period_milliseconds": typing_stopped_wait, + "server_typing_started_expiry_period_milliseconds": typing_started_expiry, + } + + initial_data.update(to_vary_in_initial_data) + model.initial_data = initial_data + model.server_feature_level = feature_level + + model._store_typing_duration_settings() + + assert model.typing_started_wait_period == typing_started_wait + assert model.typing_stopped_wait_period == typing_stopped_wait + assert model.typing_started_expiry_period == typing_started_expiry + + @pytest.mark.parametrize( + "feature_level, to_vary_in_initial_data, " + "expected_offline_threshold, expected_presence_ping_interval", + [ + (0, {}, PRESENCE_OFFLINE_THRESHOLD_SECS, PRESENCE_PING_INTERVAL_SECS), + (157, {}, PRESENCE_OFFLINE_THRESHOLD_SECS, PRESENCE_PING_INTERVAL_SECS), + ( + 164, + { + "server_presence_offline_threshold_seconds": 200, + "server_presence_ping_interval_seconds": 100, + }, + 200, + 100, + ), + ], + ids=[ + "Zulip_2.1_ZFL_0_hard_coded", + "Zulip_6.2_ZFL_157_hard_coded", + "Zulip_7.0_ZFL_164_server_provided", + ], + ) + def test__store_server_presence_intervals( + self, + model, + initial_data, + feature_level, + to_vary_in_initial_data, + expected_offline_threshold, + expected_presence_ping_interval, + ): + # Ensure inputs are not the defaults, to avoid the test accidentally passing + assert ( + to_vary_in_initial_data.get("server_presence_offline_threshold_seconds") + != PRESENCE_OFFLINE_THRESHOLD_SECS + ) + assert ( + to_vary_in_initial_data.get("server_presence_ping_interval_seconds") + != PRESENCE_PING_INTERVAL_SECS + ) + + initial_data.update(to_vary_in_initial_data) + model.initial_data = initial_data + model.server_feature_level = feature_level + + model._store_server_presence_intervals() + + assert ( + model.server_presence_offline_threshold_secs == expected_offline_threshold + ) + assert ( + model.server_presence_ping_interval_secs == expected_presence_ping_interval + ) + def test_get_message_false_first_anchor( self, mocker, @@ -1405,7 +1657,7 @@ def test_get_message_false_first_anchor( # Initialize Model self.client.register.return_value = initial_data - mocker.patch(MODEL + ".get_all_users", return_value=[]) + mocker.patch(MODEL + "._update_users_data_from_initial_data") mocker.patch(MODEL + "._subscribe_to_streams") self.classify_unread_counts = mocker.patch( MODULE + ".classify_unread_counts", return_value=[] @@ -1433,7 +1685,7 @@ def test_fail_get_messages( ): # Initialize Model self.client.register.return_value = initial_data - mocker.patch(MODEL + ".get_all_users", return_value=[]) + mocker.patch(MODEL + "._update_users_data_from_initial_data") mocker.patch(MODEL + "._subscribe_to_streams") self.classify_unread_counts = mocker.patch( MODULE + ".classify_unread_counts", return_value=[] @@ -1559,25 +1811,6 @@ def test_mark_message_ids_as_read_empty_message_view(self, model) -> None: def test__update_initial_data(self, model, initial_data): assert model.initial_data == initial_data - def test__update_initial_data_raises_exception(self, mocker, initial_data): - # Initialize Model - mocker.patch(MODEL + ".get_messages", return_value="") - mocker.patch(MODEL + ".get_all_users", return_value=[]) - mocker.patch(MODEL + "._subscribe_to_streams") - self.classify_unread_counts = mocker.patch( - MODULE + ".classify_unread_counts", return_value=[] - ) - - # Setup mocks before calling get_messages - self.client.register.return_value = initial_data - self.client.get_members.return_value = {"members": initial_data["realm_users"]} - model = Model(self.controller) - - # Test if raises Exception - self.client.register.side_effect = Exception() - with pytest.raises(Exception): - model._update_initial_data() - def test__group_info_from_realm_user_groups(self, model, user_groups_fixture): user_group_names = model._group_info_from_realm_user_groups(user_groups_fixture) assert model.user_group_by_id == { @@ -1590,6 +1823,19 @@ def test__group_info_from_realm_user_groups(self, model, user_groups_fixture): } assert user_group_names == ["Group 1", "Group 2", "Group 3", "Group 4"] + def test__clean_and_order_custom_profile_data( + self, + model, + custom_profile_fields_fixture, + custom_profile_data_fixture, + clean_custom_profile_data_fixture, + ): + model.initial_data["custom_profile_fields"] = custom_profile_fields_fixture + assert ( + clean_custom_profile_data_fixture + == model._clean_and_order_custom_profile_data(custom_profile_data_fixture) + ) + @pytest.mark.parametrize( ["to_vary_in_each_user", "key", "expected_value"], [ @@ -1645,6 +1891,26 @@ def test__group_info_from_realm_user_groups(self, model, user_groups_fixture): id="user_bot_has_owner:preZulip_3.0", ), case({}, "bot_owner_name", "", id="user_bot_has_no_owner"), + case( + { + "profile_data": { + "2": { + "value": "Simplicity", + "rendered_value": "

Simplicity

", + }, + }, + }, + "custom_profile_data", + [ + { + "label": "Biography", + "value": "Simplicity", + "type": 2, + "order": 2, + }, + ], + id="user_has_custom_profile_data", + ), ], ) def test_get_user_info( @@ -1672,7 +1938,9 @@ def test_get_user_info_sample_response( model._all_users_by_id = _all_users_by_id assert model.get_user_info(12) == tidied_user_info_response - def test_get_all_users(self, mocker, initial_data, user_list, user_dict, user_id): + def test__update_users_data_from_initial_data( + self, mocker, initial_data, user_list, user_dict, user_id + ): mocker.patch(MODEL + ".get_messages", return_value="") self.client.register.return_value = initial_data mocker.patch(MODEL + "._subscribe_to_streams") @@ -1848,7 +2116,7 @@ def test__handle_message_event_with_flags(self, mocker, model, message_fixture): "id": 1, "display_recipient": [{"id": 5827}, {"id": 5}], }, - [["pm_with", "notification-bot@zulip.com"]], + [["pm-with", "notification-bot@zulip.com"]], frozenset({5827, 5}), ["msg_w"], id="user_pm_x_appears_in_narrow_with_x", @@ -1866,7 +2134,7 @@ def test__handle_message_event_with_flags(self, mocker, model, message_fixture): "id": 1, "display_recipient": [{"id": 5827}, {"id": 3212}], }, - [["pm_with", "notification-bot@zulip.com"]], + [["pm-with", "notification-bot@zulip.com"]], frozenset({5827, 5}), [], id="user_pm_x_does_not_appear_in_narrow_without_x", @@ -1973,7 +2241,7 @@ def test__update_topic_index( ), # message_fixture sender_id is 5140 (5179, {"flags": ["mentioned"]}, False, ["stream", "private"]), (5179, {"flags": ["wildcard_mentioned"]}, False, ["stream", "private"]), - (5179, {"flags": []}, True, ["stream"]), + (5179, {"flags": []}, True, ["stream", "private"]), (5179, {"flags": []}, False, ["private"]), ], ids=[ @@ -2018,7 +2286,7 @@ def test_notify_users_calling_msg_type( # TODO: Test message content too? notify.assert_called_once_with(title, mocker.ANY) else: - notify.assert_not_called + notify.assert_not_called() @pytest.mark.parametrize( "content, expected_notification_text", @@ -2101,7 +2369,8 @@ def test_notify_users_hides_PM_content_based_on_user_setting( ) @pytest.mark.parametrize( - "event, expected_times_messages_rerendered, expected_index, topic_view_enabled", + "event, initially_me_message," + "expected_times_messages_rerendered, expected_index, topic_view_enabled", [ case( { # Only subject of 1 message is updated. @@ -2111,6 +2380,7 @@ def test_notify_users_hides_PM_content_based_on_user_setting( "stream_id": 10, "message_ids": [1], }, + False, 1, { "messages": { @@ -2119,12 +2389,14 @@ def test_notify_users_hides_PM_content_based_on_user_setting( "stream_id": 10, "content": "old content", "subject": "new subject", + "is_me_message": False, }, 2: { "id": 2, "stream_id": 10, "content": "old content", "subject": "old subject", + "is_me_message": False, }, }, "topic_msg_ids": { @@ -2144,6 +2416,7 @@ def test_notify_users_hides_PM_content_based_on_user_setting( "stream_id": 10, "message_ids": [1, 2], }, + False, 2, { "messages": { @@ -2152,12 +2425,14 @@ def test_notify_users_hides_PM_content_based_on_user_setting( "stream_id": 10, "content": "old content", "subject": "new subject", + "is_me_message": False, }, 2: { "id": 2, "stream_id": 10, "content": "old content", "subject": "new subject", + "is_me_message": False, }, }, "topic_msg_ids": { @@ -2174,7 +2449,9 @@ def test_notify_users_hides_PM_content_based_on_user_setting( "message_id": 1, "stream_id": 10, "rendered_content": "

new content

", + "is_me_message": False, }, + False, 1, { "messages": { @@ -2183,12 +2460,84 @@ def test_notify_users_hides_PM_content_based_on_user_setting( "stream_id": 10, "content": "

new content

", "subject": "old subject", + "is_me_message": False, + }, + 2: { + "id": 2, + "stream_id": 10, + "content": "old content", + "subject": "old subject", + "is_me_message": False, + }, + }, + "topic_msg_ids": { + 10: {"new subject": set(), "old subject": {1, 2}}, + }, + "edited_messages": {1}, + "topics": {10: ["new subject", "old subject"]}, + }, + False, + id="Message content is updated; both not me-messages", + ), + case( + { + "message_id": 1, + "stream_id": 10, + "rendered_content": "

/me has new content

", + "is_me_message": True, + }, + False, + 1, + { + "messages": { + 1: { + "id": 1, + "stream_id": 10, + "content": "

/me has new content

", + "subject": "old subject", + "is_me_message": True, }, 2: { "id": 2, "stream_id": 10, "content": "old content", "subject": "old subject", + "is_me_message": False, + }, + }, + "topic_msg_ids": { + 10: {"new subject": set(), "old subject": {1, 2}}, + }, + "edited_messages": {1}, + "topics": {10: ["new subject", "old subject"]}, + }, + False, + id="Message content is updated; now a me-message", + ), + case( + { + "message_id": 1, + "stream_id": 10, + "rendered_content": "

new content

", + "is_me_message": False, + }, + True, + 1, + { + "messages": { + 1: { + "id": 1, + "stream_id": 10, + "content": "

new content

", + "subject": "old subject", + "is_me_message": False, + }, + 2: { + "id": 2, + "stream_id": 10, + "content": "/me dances (old)", + "subject": "old subject", + "is_me_message": True, }, }, "topic_msg_ids": { @@ -2198,17 +2547,19 @@ def test_notify_users_hides_PM_content_based_on_user_setting( "topics": {10: ["new subject", "old subject"]}, }, False, - id="Message content is updated", + id="Message content is updated; was a me-message, not now", ), case( { # Both message content and subject is updated. "message_id": 1, "rendered_content": "

new content

", + "is_me_message": False, "orig_subject": "old subject", "subject": "new subject", "stream_id": 10, "message_ids": [1], }, + False, 2, { # 2=update of subject & content "messages": { @@ -2217,12 +2568,14 @@ def test_notify_users_hides_PM_content_based_on_user_setting( "stream_id": 10, "content": "

new content

", "subject": "new subject", + "is_me_message": False, }, 2: { "id": 2, "stream_id": 10, "content": "old content", "subject": "old subject", + "is_me_message": False, }, }, "topic_msg_ids": { @@ -2239,6 +2592,7 @@ def test_notify_users_hides_PM_content_based_on_user_setting( "message_id": 1, "foo": "boo", }, + False, 0, { "messages": { @@ -2247,12 +2601,14 @@ def test_notify_users_hides_PM_content_based_on_user_setting( "stream_id": 10, "content": "old content", "subject": "old subject", + "is_me_message": False, }, 2: { "id": 2, "stream_id": 10, "content": "old content", "subject": "old subject", + "is_me_message": False, }, }, "topic_msg_ids": { @@ -2268,11 +2624,13 @@ def test_notify_users_hides_PM_content_based_on_user_setting( { # message_id not present in index, topic view closed. "message_id": 3, "rendered_content": "

new content

", + "is_me_message": False, "orig_subject": "old subject", "subject": "new subject", "stream_id": 10, "message_ids": [3], }, + False, 0, { "messages": { @@ -2281,12 +2639,14 @@ def test_notify_users_hides_PM_content_based_on_user_setting( "stream_id": 10, "content": "old content", "subject": "old subject", + "is_me_message": False, }, 2: { "id": 2, "stream_id": 10, "content": "old content", "subject": "old subject", + "is_me_message": False, }, }, "topic_msg_ids": { @@ -2302,11 +2662,13 @@ def test_notify_users_hides_PM_content_based_on_user_setting( { # message_id not present in index, topic view is enabled. "message_id": 3, "rendered_content": "

new content

", + "is_me_message": False, "orig_subject": "old subject", "subject": "new subject", "stream_id": 10, "message_ids": [3], }, + False, 0, { "messages": { @@ -2315,12 +2677,14 @@ def test_notify_users_hides_PM_content_based_on_user_setting( "stream_id": 10, "content": "old content", "subject": "old subject", + "is_me_message": False, }, 2: { "id": 2, "stream_id": 10, "content": "old content", "subject": "old subject", + "is_me_message": False, }, }, "topic_msg_ids": { @@ -2336,11 +2700,13 @@ def test_notify_users_hides_PM_content_based_on_user_setting( { # Message content is updated and topic view is enabled. "message_id": 1, "rendered_content": "

new content

", + "is_me_message": False, "orig_subject": "old subject", "subject": "new subject", "stream_id": 10, "message_ids": [1], }, + False, 2, { "messages": { @@ -2349,12 +2715,14 @@ def test_notify_users_hides_PM_content_based_on_user_setting( "stream_id": 10, "content": "

new content

", "subject": "new subject", + "is_me_message": False, }, 2: { "id": 2, "stream_id": 10, "content": "old content", "subject": "old subject", + "is_me_message": False, }, }, "topic_msg_ids": { @@ -2373,20 +2741,23 @@ def test__handle_update_message_event( mocker, model, event, + initially_me_message, expected_index, expected_times_messages_rerendered, topic_view_enabled, ): event["type"] = "update_message" + initial_message_data = { # for all messages in index, base data + "stream_id": 10, + "content": "/me dances (old)" if initially_me_message else "old content", + "subject": "old subject", + "is_me_message": initially_me_message, + } + model.index = { "messages": { - message_id: { - "id": message_id, - "stream_id": 10, - "content": "old content", - "subject": "old subject", - } + message_id: dict(initial_message_data, id=message_id) for message_id in [1, 2] }, "topic_msg_ids": { # FIXME? consider test for eg. absence of empty set @@ -2491,21 +2862,25 @@ def test__update_rendered_view_change_narrow( @pytest.fixture def reaction_event_factory(self): - def _factory(*, op: str, message_id: int): - return { - "emoji_code": "1f44d", + def _factory(*, op: str, message_id: int, user=None, user_id=None): + base_event = { "id": 2, - "user": { - "email": "Foo@zulip.com", - "user_id": 5140, - "full_name": "Foo Boo", - }, + "emoji_code": "1f44d", "reaction_type": "unicode_emoji", "message_id": message_id, "emoji_name": "thumbs_up", "type": "reaction", "op": op, } + if user is not None: + base_event["user"] = { + "email": "Foo@zulip.com", + "user_id": 5140, + "full_name": "Foo Boo", + } + if user_id is not None: + base_event["user_id"] = user_id + return base_event return _factory @@ -2514,28 +2889,45 @@ def reaction_event_index_factory(self): """ Generate index for reaction tests based on minimal specification - Input is a list of pairs, of a message-id and a list of reaction tuples + Input: + - msgs: A list of tuples where each tuple contains: + - A message ID + - A list of reaction tuples, where each reaction includes: + - user_id + - reaction_type + - emoji_code + - emoji_name + - schema: Defines the fields present in the reaction + (e.g., "with_user", "with_user_id", "with_both") + NOTE: reactions as None indicate not indexed, [] indicates no reaction """ MsgsType = List[Tuple[int, Optional[List[Tuple[int, str, str, str]]]]] - def _factory(msgs: MsgsType): + def _factory(msgs: MsgsType, schema="with_user"): + def build_reaction(user_id, type, code, name): + reaction = { + "reaction_type": type, + "emoji_code": code, + "emoji_name": name, + } + if schema in {"with_user", "with_both"}: + reaction["user"] = { + "email": f"User email #{user_id}", + "full_name": f"User #{user_id}", + "id": user_id, + } + if schema in {"with_user_id", "with_both"}: + reaction["user_id"] = user_id + return reaction + return { "messages": { message_id: { "id": message_id, "content": f"message content {message_id}", "reactions": [ - { - "user": { - "email": f"User email #{user_id}", - "full_name": f"User #{user_id}", - "user_id": user_id, - }, - "reaction_type": type, - "emoji_code": code, - "emoji_name": name, - } + build_reaction(user_id, type, code, name) for user_id, type, code, name in reactions ], } @@ -2547,6 +2939,12 @@ def _factory(msgs: MsgsType): return _factory @pytest.mark.parametrize("op", ["add", "remove"]) + @pytest.mark.parametrize( + "reaction_event_schema", ["with_user", "with_user_id", "with_both"] + ) + @pytest.mark.parametrize( + "reaction_schema", ["with_user", "with_user_id", "with_both"] + ) def test__handle_reaction_event_not_in_index( self, mocker, @@ -2554,18 +2952,30 @@ def test__handle_reaction_event_not_in_index( reaction_event_factory, reaction_event_index_factory, op, + reaction_event_schema, + reaction_schema, unindexed_message_id=1, ): - reaction_event = reaction_event_factory( - op=op, - message_id=unindexed_message_id, - ) + common_args = { + "op": op, + "message_id": unindexed_message_id, + } + if reaction_event_schema == "with_user": + reaction_event = reaction_event_factory(**common_args, user=True) + elif reaction_event_schema == "with_user_id": + reaction_event = reaction_event_factory(**common_args, user_id=5140) + else: + reaction_event = reaction_event_factory( + **common_args, user=True, user_id=5140 + ) + model.index = reaction_event_index_factory( [ (unindexed_message_id, None), # explicitly exclude (2, [(1, "unicode_emoji", "1232", "thumbs_up")]), (3, []), - ] + ], + reaction_schema, ) model._update_rendered_view = mocker.Mock() previous_index = deepcopy(model.index) @@ -2577,10 +2987,116 @@ def test__handle_reaction_event_not_in_index( assert not model._update_rendered_view.called @pytest.mark.parametrize( - "op, expected_number_after", + "reaction_event_schema", ["with_user", "with_user_id", "with_both"] + ) + @pytest.mark.parametrize( + "reaction_schema", ["with_user", "with_user_id", "with_both"] + ) + @pytest.mark.parametrize( + "msgs, op, expected_number_after", [ - ("add", 2), - ("remove", 1), # Removed emoji doesn't match, so length remains 1 + case( + [ + (2, [(5140, "unicode_emoji", "2764", "heart")]), + (1, []), + ], + "add", + 2, + id="single_reaction_add", + ), + case( + [ + (2, [(5140, "unicode_emoji", "1f44d", "thumbs_up")]), + (1, []), + ], + "remove", + 0, + id="single_reaction_remove", + ), + case( + [ + ( + 2, + [ + (3478, "unicode_emoji", "1f44d", "thumbs_up"), + (3479, "unicode_emoji", "1f44d", "thumbs_up"), + ], + ), + ], + "add", + 3, + id="same_emoji_different_users_add", + ), + case( + [ + ( + 2, + [ + (3478, "unicode_emoji", "1f44d", "thumbs_up"), + (5140, "unicode_emoji", "1f44d", "thumbs_up"), + ], + ), + ], + "remove", + 1, + id="same_emoji_different_users_remove", + ), + case( + [ + ( + 2, + [ + (5140, "zulip_extra_emoji", "zulip", "zulip"), + (3478, "unicode_emoji", "2764", "heart"), + ], + ), + ], + "add", + 3, + id="different_emoji_different_users_add", + ), + case( + [ + ( + 2, + [ + (5140, "unicode_emoji", "1f44d", "thumbs_up"), + (3478, "unicode_emoji", "2764", "heart"), + ], + ), + ], + "remove", + 1, + id="different_emoji_different_users_remove", + ), + case( + [ + ( + 2, + [ + (5140, "zulip_extra_emoji", "zulip", "zulip"), + (5140, "unicode_emoji", "2764", "heart"), + ], + ), + ], + "add", + 3, + id="different_emoji_same_user_add", + ), + case( + [ + ( + 2, + [ + (5140, "unicode_emoji", "1f44d", "thumbs_up"), + (5140, "unicode_emoji", "5678", "heart"), + ], + ), + ], + "remove", + 1, + id="different_emoji_same_user_remove", + ), ], ) def test__handle_reaction_event_for_msg_in_index( @@ -2590,19 +3106,26 @@ def test__handle_reaction_event_for_msg_in_index( reaction_event_factory, reaction_event_index_factory, op, + reaction_event_schema, + reaction_schema, + msgs, expected_number_after, - event_message_id=1, + event_message_id=2, ): - reaction_event = reaction_event_factory( - op=op, - message_id=event_message_id, - ) - model.index = reaction_event_index_factory( - [ - (1, [(1, "unicode_emoji", "1232", "thumbs_up")]), - (2, []), - ] - ) + common_args = { + "op": op, + "message_id": event_message_id, + } + if reaction_event_schema == "with_user": + reaction_event = reaction_event_factory(**common_args, user=True) + elif reaction_event_schema == "with_user_id": + reaction_event = reaction_event_factory(**common_args, user_id=5140) + else: + reaction_event = reaction_event_factory( + **common_args, user=True, user_id=5140 + ) + + model.index = reaction_event_index_factory(msgs, reaction_schema) model._update_rendered_view = mocker.Mock() model._handle_reaction_event(reaction_event) @@ -2612,11 +3135,240 @@ def test__handle_reaction_event_for_msg_in_index( model._update_rendered_view.assert_called_once_with(event_message_id) + @pytest.mark.parametrize( + "submessages, event, expected_updated_submessage", + [ + case( + [ + { + "id": 1, + "message_id": 1958326, + "sender_id": 27294, + "msg_type": "widget", + "content": ( + '{"widget_type": "todo", "extra_data": ' + '{"task_list_title": "Today\'s Work", "tasks": [{"task"' + ': "Handle submessages events on ZT", "desc": ""}, {"task":' + ' "Play ping pong", "desc": ""}]}}' + ), + } + ], + { + "type": "submessage", + "msg_type": "widget", + "message_id": 1958326, + "submessage_id": 1, + "sender_id": 27294, + "content": '{"type":"strike","key":"0,canned"}', + "id": 1, + }, + [ + { + "id": 1, + "message_id": 1958326, + "sender_id": 27294, + "msg_type": "widget", + "content": ( + '{"widget_type": "todo", "extra_data": ' + '{"task_list_title": "Today\'s Work", "tasks": ' + '[{"task": "Handle submessages events on ZT", "desc": ""}, ' + '{"task": "Play ping pong", "desc": ""}]}}' + ), + }, + { + "type": "submessage", + "msg_type": "widget", + "message_id": 1958326, + "submessage_id": 1, + "sender_id": 27294, + "content": '{"type":"strike","key":"0,canned"}', + }, + ], + id="submessage_strike_event_todo_widget", + ), + case( + [ + { + "id": 1, + "message_id": 1958326, + "sender_id": 27294, + "msg_type": "widget", + "content": ( + '{"widget_type": "todo", "extra_data": ' + '{"task_list_title": "Today\'s Work", "tasks": [{"task": ' + '"Handle submessages events on ZT", "desc": ""}, {"task": ' + '"Play ping pong", "desc": ""}]}}' + ), + }, + { + "id": 12154, + "message_id": 1958326, + "sender_id": 27294, + "msg_type": "widget", + "content": '{"type":"strike","key":"0,canned"}', + }, + ], + { + "type": "submessage", + "msg_type": "widget", + "message_id": 1958326, + "submessage_id": 12185, + "sender_id": 27294, + "content": ( + '{"type":"new_task","key":2,"task":"Make a coffee",' + '"desc":"","completed":false}' + ), + "id": 0, + }, + [ + { + "id": 1, + "message_id": 1958326, + "sender_id": 27294, + "msg_type": "widget", + "content": ( + '{"widget_type": "todo", "extra_data": ' + '{"task_list_title": "Today\'s Work", "tasks": [{"task": ' + '"Handle submessages events on ZT", "desc": ""}, {"task": ' + '"Play ping pong", "desc": ""}]}}' + ), + }, + { + "id": 12154, + "message_id": 1958326, + "sender_id": 27294, + "msg_type": "widget", + "content": '{"type":"strike","key":"0,canned"}', + }, + { + "type": "submessage", + "msg_type": "widget", + "message_id": 1958326, + "submessage_id": 12185, + "sender_id": 27294, + "content": ( + '{"type":"new_task","key":2,"task":"Make a coffee",' + '"desc":"","completed":false}' + ), + }, + ], + id="submessage_new_task_event_todo_widget", + ), + case( + [ + { + "id": 12153, + "message_id": 1958326, + "sender_id": 27294, + "msg_type": "widget", + "content": ( + '{"widget_type": "todo", "extra_data": ' + '{"task_list_title": "Today\'s Work", "tasks": [{"task": ' + '"Handle submessages events on ZT", "desc": ""}, {"task": ' + '"Play ping pong", "desc": ""}]}}' + ), + }, + { + "id": 12154, + "message_id": 1958326, + "sender_id": 27294, + "msg_type": "widget", + "content": '{"type":"strike","key":"0,canned"}', + }, + { + "type": "submessage", + "msg_type": "widget", + "message_id": 1958326, + "submessage_id": 12185, + "sender_id": 27294, + "content": ( + '{"type":"new_task","key":2,"task":"Make a coffee"' + ',"desc":"","completed":false}' + ), + "id": 0, + }, + ], + { + "type": "submessage", + "msg_type": "widget", + "message_id": 1958326, + "submessage_id": 12186, + "sender_id": 27294, + "content": ( + '{"type":"new_task_list_title","title":"Today\'s Work ' + '[Updated]"}' + ), + "id": 11, + }, + [ + { + "id": 12153, + "message_id": 1958326, + "sender_id": 27294, + "msg_type": "widget", + "content": ( + '{"widget_type": "todo", "extra_data": ' + '{"task_list_title": "Today\'s Work", "tasks": [{"task": ' + '"Handle submessages events on ZT", "desc": ""}, {"task": ' + '"Play ping pong", "desc": ""}]}}' + ), + }, + { + "id": 12154, + "message_id": 1958326, + "sender_id": 27294, + "msg_type": "widget", + "content": '{"type":"strike","key":"0,canned"}', + }, + { + "type": "submessage", + "msg_type": "widget", + "message_id": 1958326, + "submessage_id": 12185, + "sender_id": 27294, + "content": ( + '{"type":"new_task","key":2,"task":"Make a coffee"' + ',"desc":"","completed":false}' + ), + "id": 0, + }, + { + "type": "submessage", + "msg_type": "widget", + "message_id": 1958326, + "submessage_id": 12186, + "sender_id": 27294, + "content": ( + '{"type":"new_task_list_title",' + '"title":"Today\'s Work [Updated]"}' + ), + }, + ], + id="submessage_new_task_list_title_event_todo_widget", + ), + ], + ) + def test__handle_submessage_event( + self, + mocker, + model, + submessages, + event, + expected_updated_submessage, + id=1958326, + ): + model.index["messages"][id]["submessages"] = submessages + model._update_rendered_view = mocker.Mock() + + model._handle_submessage_event(event) + + assert model.index["messages"][id]["submessages"] == expected_updated_submessage + @pytest.fixture( params=[ ("op", 32), # At server feature level 32, event uses standard field ("operation", 31), - ("operation", None), + ("operation", 0), ] ) def update_message_flags_operation(self, request): @@ -2904,7 +3656,7 @@ def test_toggle_stream_visual_notifications( id="not_in_pm_narrow", ), case( - [["pm_with", "othello@zulip.com"]], + [["pm-with", "othello@zulip.com"]], { "op": "start", "sender": {"user_id": 4, "email": "hamlet@zulip.com"}, @@ -2920,7 +3672,7 @@ def test_toggle_stream_visual_notifications( id="not_in_pm_narrow_with_sender", ), case( - [["pm_with", "hamlet@zulip.com"]], + [["pm-with", "hamlet@zulip.com"]], { "op": "start", "sender": {"user_id": 4, "email": "hamlet@zulip.com"}, @@ -2936,7 +3688,7 @@ def test_toggle_stream_visual_notifications( id="in_pm_narrow_with_sender_typing:start", ), case( - [["pm_with", "hamlet@zulip.com"]], + [["pm-with", "hamlet@zulip.com"]], { "op": "start", "sender": {"user_id": 4, "email": "hamlet@zulip.com"}, @@ -2952,7 +3704,7 @@ def test_toggle_stream_visual_notifications( id="in_pm_narrow_with_sender_typing:start_while_animation_in_progress", ), case( - [["pm_with", "hamlet@zulip.com"]], + [["pm-with", "hamlet@zulip.com"]], { "op": "stop", "sender": {"user_id": 4, "email": "hamlet@zulip.com"}, @@ -2968,7 +3720,7 @@ def test_toggle_stream_visual_notifications( id="in_pm_narrow_with_sender_typing:stop", ), case( - [["pm_with", "hamlet@zulip.com"]], + [["pm-with", "hamlet@zulip.com"]], { "op": "start", "sender": {"user_id": 5, "email": "iago@zulip.com"}, @@ -2984,7 +3736,7 @@ def test_toggle_stream_visual_notifications( id="in_pm_narrow_with_other_myself_typing:start", ), case( - [["pm_with", "hamlet@zulip.com"]], + [["pm-with", "hamlet@zulip.com"]], { "op": "stop", "sender": {"user_id": 5, "email": "iago@zulip.com"}, @@ -3000,7 +3752,7 @@ def test_toggle_stream_visual_notifications( id="in_pm_narrow_with_other_myself_typing:stop", ), case( - [["pm_with", "iago@zulip.com"]], + [["pm-with", "iago@zulip.com"]], { "op": "start", "sender": {"user_id": 5, "email": "iago@zulip.com"}, @@ -3013,7 +3765,7 @@ def test_toggle_stream_visual_notifications( id="in_pm_narrow_with_oneself:start", ), case( - [["pm_with", "iago@zulip.com"]], + [["pm-with", "iago@zulip.com"]], { "op": "stop", "sender": {"user_id": 5, "email": "iago@zulip.com"}, @@ -3136,11 +3888,11 @@ def test__handle_typing_event( ), ], ids=[ - "remove_18_in_home_view:already_unmuted:ZFLNone", - "remove_19_in_home_view:muted:ZFLNone", - "add_19_in_home_view:already_muted:ZFLNone", - "add_30_in_home_view:unmuted:ZFLNone", - "remove_30_is_muted:already_unmutedZFL139", + "remove_18_in_home_view:already_unmuted:ZFL0", + "remove_19_in_home_view:muted:ZFL0", + "add_19_in_home_view:already_muted:ZFL0", + "add_30_in_home_view:unmuted:ZFL0", + "remove_30_is_muted:already_unmuted:ZFL139", "remove_19_is_muted:muted:ZFL139", "add_15_is_muted:already_muted:ZFL139", "add_30_is_muted:unmuted:ZFL139", @@ -3308,7 +4060,7 @@ def test__handle_subscription_event_visual_notifications( [ ( {"op": "peer_add", "stream_id": 99, "user_id": 12}, - None, + 0, 99, [1001, 11, 12], ), @@ -3330,7 +4082,7 @@ def test__handle_subscription_event_visual_notifications( 99, [1001, 11, 12], ), - ({"op": "peer_remove", "stream_id": 2, "user_id": 12}, None, 2, [1001, 11]), + ({"op": "peer_remove", "stream_id": 2, "user_id": 12}, 0, 2, [1001, 11]), ({"op": "peer_remove", "stream_id": 2, "user_id": 12}, 34, 2, [1001, 11]), ( {"op": "peer_remove", "stream_ids": [2], "user_ids": [12]}, @@ -3346,11 +4098,11 @@ def test__handle_subscription_event_visual_notifications( ), ], ids=[ - "user_subscribed_to_stream:ZFLNone", + "user_subscribed_to_stream:ZFL0", "user_subscribed_to_stream:ZFL34", "user_subscribed_to_stream:ZFL34_should_be_35", "user_subscribed_to_stream:ZFL35", - "user_unsubscribed_from_stream:ZFLNone", + "user_unsubscribed_from_stream:ZFL0", "user_unsubscribed_from_stream:ZFL34", "user_unsubscribed_from_stream:ZFL34_should_be_35", "user_unsubscribed_from_stream:ZFL35", @@ -3497,7 +4249,7 @@ def test__handle_subscription_event_subscribers_one_user_multiple_streams( "delivery_email", ], ) - def test__handle_realm_user_event( + def test__handle_realm_user_event__general( self, person, event_field, updated_field_if_different, model, initial_data ): # id 11 matches initial_data["realm_users"][1] in the initial_data fixture @@ -3515,6 +4267,165 @@ def test__handle_realm_user_event( == event["person"][event_field] ) + @pytest.mark.parametrize("user_id", [11, 12], ids=["no_custom", "many_custom"]) + @pytest.mark.parametrize( + "update_data, expected_modified_field_id", + [ + case( + { + "id": 1, + "value": "7237032732", + "rendered_value": "

7237032732

", + }, + "1", + id="Short Text 1", + ), + case( + { + "id": 2, + "value": "Complexity", + "rendered_value": "

Complexity

", + }, + "2", + id="Long Text", + ), + case( + { + "id": 3, + "value": "pizza", + "rendered_value": "

pizza

", + }, + "3", + id="Short Text 2", + ), + case( + { + "id": 4, + "value": "0", + }, + "4", + id="List of Options", + ), + case( + { + "id": 5, + "value": "2023-04-22", + }, + "5", + id="Date Picker", + ), + case( + { + "id": 6, + "value": "https://www.google.com", + }, + "6", + id="Link", + ), + case( + { + "id": 7, + "value": "[13]", + }, + "7", + id="Person Picker", + ), + case( + { + "id": 9, + "value": "githubmaster", + }, + "9", + id="External Account", + ), + case( + { + "id": 12, + "value": "she/her", + }, + "12", + id="Pronouns", + ), + ], + ) + def test__handle_realm_user_event__custom_profile_data__update_data( + self, + user_id, + update_data, + expected_modified_field_id, + model, + initial_data, + ): + REALM_USER_INDEX = user_id - 10 + user_data = initial_data["realm_users"][REALM_USER_INDEX] + # Ensure indices match user id + assert user_data["user_id"] == user_id, "unexpected test configuration" + # Updated value is expected to vary from existing value + assert ( + user_data["profile_data"].get(update_data["id"], {}).get("value", None) + != update_data["value"] + ), "unexpected test configuration" + + person = {"custom_profile_field": update_data, "user_id": user_id} + event = {"type": "realm_user", "op": "update", "id": 1000, "person": person} + + profile_data_before_update = copy.deepcopy(user_data["profile_data"]) + expected_profile_data = { + key: update_data[key] for key in update_data if key != "id" + } + + model._handle_realm_user_event(event) + + assert ( + user_data["profile_data"][expected_modified_field_id] + == expected_profile_data + ) + assert all( + user_data["profile_data"][field_id] == profile_data_before_update[field_id] + for field_id in profile_data_before_update + if field_id != expected_modified_field_id + ) + + @pytest.mark.parametrize("user_id", [11, 12], ids=["no_custom", "many_custom"]) + @pytest.mark.parametrize( + "update_data, expected_removed_field_id", + [ + ( + { + "id": 8, + "value": None, + }, + "8", + ), + ], + ) + def test__handle_realm_user_event__custom_profile_data__remove_data( + self, + user_id, + update_data, + expected_removed_field_id, + model, + initial_data, + ): + REALM_USER_INDEX = user_id - 10 + user_data = initial_data["realm_users"][REALM_USER_INDEX] + # Ensure indices match user id + assert user_data["user_id"] == user_id, "unexpected test configuration" + + person = {"custom_profile_field": update_data, "user_id": user_id} + event = {"type": "realm_user", "op": "update", "id": 1000, "person": person} + + profile_data_before_update = copy.deepcopy(user_data["profile_data"]) + + model._handle_realm_user_event(event) + + assert expected_removed_field_id not in user_data["profile_data"] + assert all( + user_data["profile_data"][field_id] == profile_data_before_update[field_id] + for field_id in profile_data_before_update + if field_id != expected_removed_field_id + ) + @pytest.mark.parametrize("value", [True, False]) def test__handle_user_settings_event(self, mocker, model, value): setting = "send_private_typing_notifications" @@ -3621,7 +4532,7 @@ def test_is_muted_topic( assert return_value == is_muted @pytest.mark.parametrize( - "unread_topics, last_unread_topic, next_unread_topic", + "unread_topics, current_topic, next_unread_topic", [ case( {(1, "topic"), (2, "topic2")}, @@ -3641,10 +4552,10 @@ def test_is_muted_topic( (1, "topic"), id="unread_present_before_previous_topic", ), - case( # TODO Should be None? (2 other cases) + case( {(1, "topic")}, (1, "topic"), - (1, "topic"), + None, id="unread_still_present_in_topic", ), case( @@ -3714,16 +4625,48 @@ def test_is_muted_topic( (2, "topic2"), id="unread_present_after_previous_topic_muted", ), + case( + {(1, "topic1"), (2, "topic2"), (2, "topic2 muted")}, + (2, "topic1"), + (2, "topic2"), + id="unmuted_unread_present_in_same_stream_as_current_topic_not_in_unread_list", + ), + case( + {(1, "topic1"), (2, "topic2 muted"), (4, "topic4")}, + (2, "topic1"), + (4, "topic4"), + id="unmuted_unread_present_in_next_stream_as_current_topic_not_in_unread_list", + ), + case( + {(1, "topic1"), (2, "topic2 muted"), (3, "topic3")}, + (2, "topic1"), + (1, "topic1"), + id="unmuted_unread_not_present_in_next_stream_as_current_topic_not_in_unread_list", + ), + case( + {(1, "topic1"), (1, "topic11"), (2, "topic2")}, + (1, "topic11"), + (1, "topic1"), + id="unread_present_in_same_stream_wrap_around", + ), + case( + {(1, "topic1"), (5, "topic5"), (2, "topic2")}, + (2, "topic2"), + (5, "topic5"), + id="streams_sorted_according_to_left_panel", + ), ], ) - def test_get_next_unread_topic( - self, model, unread_topics, last_unread_topic, next_unread_topic + def test_next_unread_topic_from_message( + self, mocker, model, unread_topics, current_topic, next_unread_topic ): # NOTE Not important how many unreads per topic, so just use '1' model.unread_counts = { "unread_topics": {stream_topic: 1 for stream_topic in unread_topics} } - model._last_unread_topic = last_unread_topic + + current_message_id = 10 # Arbitrary value due to mock below + model.stream_topic_from_message_id = mocker.Mock(return_value=current_topic) # Minimal extra streams for muted stream testing (should not exist otherwise) assert {3, 4} & set(model.stream_dict) == set() @@ -3731,6 +4674,20 @@ def test_get_next_unread_topic( model.stream_dict[4] = {"name": "Stream 4"} model.muted_streams = {3} + # Extra stream for stream sort testing (should not exist otherwise) + assert 5 not in model.stream_dict + model.stream_dict[5] = {"name": "First Stream"} + + model.pinned_streams = [ + {"name": "First Stream", "id": 5}, + {"name": "Stream 1", "id": 1}, + {"name": "Stream 2", "id": 2}, + ] + model.unpinned_streams = [ + {"name": "Stream 3", "id": 3}, + {"name": "Stream 4", "id": 4}, + ] + # date data unimportant (if present) model._muted_topics = { stream_topic: None @@ -3741,10 +4698,84 @@ def test_get_next_unread_topic( ] } - unread_topic = model.get_next_unread_topic() + unread_topic = model.next_unread_topic_from_message_id(current_message_id) + + assert unread_topic == next_unread_topic + + @pytest.mark.parametrize( + "unread_topics, empty_narrow, narrow_stream_id, next_unread_topic", + [ + case( + {(1, "topic1"), (1, "topic2"), (2, "topic3")}, + [["stream", "Stream 1"], ["topic", "topic1.5"]], + 1, + (1, "topic2"), + ), + ], + ) + def test_next_unread_topic_from_message__empty_narrow( + self, + mocker, + model, + unread_topics, + empty_narrow, + narrow_stream_id, + next_unread_topic, + ): + # NOTE Not important how many unreads per topic, so just use '1' + model.unread_counts = { + "unread_topics": {stream_topic: 1 for stream_topic in unread_topics} + } + model.pinned_streams = [ + {"name": "Stream 1", "id": 1}, + {"name": "Stream 2", "id": 2}, + ] + model.unpinned_streams = [ + {"name": "Stream 3", "id": 3}, + {"name": "Stream 4", "id": 4}, + ] + + model.stream_id_from_name = mocker.Mock(return_value=narrow_stream_id) + model.narrow = empty_narrow + + unread_topic = model.next_unread_topic_from_message_id(None) assert unread_topic == next_unread_topic + def test_get_next_unread_pm(self, model): + model.unread_counts = {"unread_pms": {1: 1, 2: 1}} + return_value = model.get_next_unread_pm() + assert return_value == 1 + assert model.last_unread_pm == 1 + + def test_get_next_unread_pm_again(self, model): + model.unread_counts = {"unread_pms": {1: 1, 2: 1}} + model.last_unread_pm = 1 + return_value = model.get_next_unread_pm() + assert return_value == 2 + assert model.last_unread_pm == 2 + + def test_get_next_unread_pm_no_unread(self, model): + model.unread_counts = {"unread_pms": {}} + return_value = model.get_next_unread_pm() + assert return_value is None + assert model.last_unread_pm is None + + @pytest.mark.parametrize( + "message_id, expected_value", + [ + case(537286, (205, "Test"), id="stream_message"), + case(537287, None, id="direct_message"), + case(537289, None, id="non-existent message"), + ], + ) + def test_stream_topic_from_message_id( + self, mocker, model, message_id, expected_value, empty_index + ): + model.index = empty_index + current_topic = model.stream_topic_from_message_id(message_id) + assert current_topic == expected_value + @pytest.mark.parametrize( "stream_id, expected_response", [ diff --git a/tests/ui/test_ui.py b/tests/ui/test_ui.py index f047bbc516..d017f4697a 100644 --- a/tests/ui/test_ui.py +++ b/tests/ui/test_ui.py @@ -341,6 +341,23 @@ def test_keypress_STREAM_MESSAGE( assert returned_key == key assert view.body.focus_col == 1 + @pytest.mark.parametrize("key", keys_for_command("NEW_HINT")) + def test_keypress_NEW_HINT( + self, + view: View, + mocker: MockerFixture, + key: str, + widget_size: Callable[[Widget], urwid_Box], + ) -> None: + size = widget_size(view) + set_footer_text = mocker.patch(VIEW + ".set_footer_text") + mocker.patch(CONTROLLER + ".is_in_editor_mode", return_value=False) + + returned_key = view.keypress(size, key) + + set_footer_text.assert_called_once_with() + assert returned_key == key + @pytest.mark.parametrize("key", keys_for_command("SEARCH_PEOPLE")) @pytest.mark.parametrize("autohide", [True, False], ids=["autohide", "no_autohide"]) def test_keypress_autohide_users( @@ -419,7 +436,7 @@ def test_keypress_OPEN_DRAFT( self, view: View, mocker: MockerFixture, - draft: Composition, + draft: Optional[Composition], key: str, autohide: bool, widget_size: Callable[[Widget], urwid_Box], diff --git a/tests/ui/test_ui_tools.py b/tests/ui/test_ui_tools.py index bb713403a1..0a4d9ec030 100644 --- a/tests/ui/test_ui_tools.py +++ b/tests/ui/test_ui_tools.py @@ -1,6 +1,8 @@ from collections import OrderedDict import pytest +import urwid +from pytest import param as case from urwid import Divider from zulipterminal.config.keys import keys_for_command, primary_key_for_command @@ -29,8 +31,9 @@ class TestModListWalker: @pytest.fixture - def mod_walker(self): - return ModListWalker([list(range(1))]) + def mod_walker(self, mocker): + read_message = mocker.Mock(spec=lambda: None) + return ModListWalker(contents=[list(range(1))], action=read_message) @pytest.mark.parametrize( "num_items, focus_position", @@ -46,14 +49,14 @@ def test_extend(self, num_items, focus_position, mod_walker, mocker): mod_walker._set_focus.assert_called_once_with(focus_position) def test__set_focus(self, mod_walker, mocker): - mod_walker.read_message = mocker.Mock() + mod_walker._action.assert_not_called() mod_walker._set_focus(0) - mod_walker.read_message.assert_called_once_with() + mod_walker._action.assert_called_once_with() def test_set_focus(self, mod_walker, mocker): - mod_walker.read_message = mocker.Mock() + mod_walker._action.assert_not_called() mod_walker.set_focus(0) - mod_walker.read_message.assert_called_once_with() + mod_walker._action.assert_called_once_with() class TestMessageView: @@ -79,8 +82,10 @@ def test_init(self, mocker, msg_view, msg_box): assert msg_view.old_loading is False assert msg_view.new_loading is False - @pytest.mark.parametrize("narrow_focus_pos, focus_msg", [(set(), 1), (0, 0)]) - def test_main_view(self, mocker, narrow_focus_pos, focus_msg): + @pytest.mark.parametrize( + "narrow_focus_pos, expected_focus_msg", [(None, 1), (0, 0)] + ) + def test_main_view(self, mocker, narrow_focus_pos, expected_focus_msg): mocker.patch(MESSAGEVIEW + ".read_message") self.urwid.SimpleFocusListWalker.return_value = mocker.Mock() mocker.patch(MESSAGEVIEW + ".set_focus") @@ -90,7 +95,7 @@ def test_main_view(self, mocker, narrow_focus_pos, focus_msg): msg_view = MessageView(self.model, self.view) - assert msg_view.focus_msg == focus_msg + assert msg_view.focus_msg == expected_focus_msg @pytest.mark.parametrize( "messages_fetched", @@ -386,8 +391,7 @@ def test_message_calls_search_and_header_bar(self, mocker, msg_view): msg_w = mocker.MagicMock() msg_w.original_widget.message = {"id": 1} msg_view.update_search_box_narrow(msg_w.original_widget) - msg_w.original_widget.top_header_bar.assert_called_once_with - (msg_w.original_widget) + msg_w.original_widget.recipient_header.assert_called_once_with() msg_w.original_widget.top_search_bar.assert_called_once_with() def test_read_message_no_msgw(self, mocker, msg_view): @@ -555,8 +559,8 @@ def test_keypress_SEARCH_STREAMS(self, mocker, stream_view, key, widget_size): stream_view.stream_search_box ) - @pytest.mark.parametrize("key", keys_for_command("GO_BACK")) - def test_keypress_GO_BACK(self, mocker, stream_view, key, widget_size): + @pytest.mark.parametrize("key", keys_for_command("CLEAR_SEARCH")) + def test_keypress_CLEAR_SEARCH(self, mocker, stream_view, key, widget_size): size = widget_size(stream_view) mocker.patch.object(stream_view, "set_focus") mocker.patch(VIEWS + ".urwid.Frame.keypress") @@ -604,9 +608,41 @@ def test_init(self, mocker, topic_view): topic_view, "SEARCH_TOPICS", topic_view.update_topics ) self.header_list.assert_called_once_with( - [topic_view.stream_button, self.divider("─"), topic_view.topic_search_box] + [ + topic_view.stream_button, + self.divider("─"), + topic_view.topic_search_box, + self.divider("─"), + ] + ) + + @pytest.mark.parametrize( + "stream_id, saved_topic_state, expected_focus_index", + [ + case(1, None, 0, id="initial_condition_no_topic_is_stored"), + case(2, "Topic 3", 2, id="topic_is_stored_and_present_in_topic_list"), + case(3, "Topic 4", 0, id="topic_is_stored_but_not_present_in_topic_list"), + ], + ) + def test__focus_position_for_topic_name( + self, mocker, stream_id, saved_topic_state, topic_view, expected_focus_index + ): + topic_view.stream_button.stream_id = stream_id + topic_view.list_box = mocker.MagicMock(spec=urwid.ListBox) + topic_view.list_box.body = [ + mocker.Mock(topic_name="Topic 1"), + mocker.Mock(topic_name="Topic 2"), + mocker.Mock(topic_name="Topic 3"), + ] + topic_view.log = urwid.SimpleFocusListWalker(topic_view.list_box.body) + mocker.patch.object( + topic_view.view, "saved_topic_in_stream_id", return_value=saved_topic_state ) + new_focus_index = topic_view._focus_position_for_topic_name() + + assert new_focus_index == expected_focus_index + @pytest.mark.parametrize( "new_text, expected_log", [ @@ -689,8 +725,8 @@ def test_keypress_SEARCH_TOPICS(self, mocker, topic_view, key, widget_size): topic_view.topic_search_box ) - @pytest.mark.parametrize("key", keys_for_command("GO_BACK")) - def test_keypress_GO_BACK(self, mocker, topic_view, key, widget_size): + @pytest.mark.parametrize("key", keys_for_command("CLEAR_SEARCH")) + def test_keypress_CLEAR_SEARCH(self, mocker, topic_view, key, widget_size): size = widget_size(topic_view) mocker.patch(VIEWS + ".TopicsView.set_focus") mocker.patch(VIEWS + ".urwid.Frame.keypress") @@ -804,32 +840,12 @@ def mid_col_view(self): def test_init(self, mid_col_view): assert mid_col_view.model == self.model assert mid_col_view.controller == self.model.controller - assert mid_col_view.last_unread_pm is None assert mid_col_view.search_box == self.search_box assert self.view.message_view == "MSG_LIST" self.super.assert_called_once_with( "MSG_LIST", header=self.search_box, footer=self.write_box ) - def test_get_next_unread_pm(self, mid_col_view): - mid_col_view.model.unread_counts = {"unread_pms": {1: 1, 2: 1}} - return_value = mid_col_view.get_next_unread_pm() - assert return_value == 1 - assert mid_col_view.last_unread_pm == 1 - - def test_get_next_unread_pm_again(self, mid_col_view): - mid_col_view.model.unread_counts = {"unread_pms": {1: 1, 2: 1}} - mid_col_view.last_unread_pm = 1 - return_value = mid_col_view.get_next_unread_pm() - assert return_value == 2 - assert mid_col_view.last_unread_pm == 2 - - def test_get_next_unread_pm_no_unread(self, mid_col_view): - mid_col_view.model.unread_counts = {"unread_pms": {}} - return_value = mid_col_view.get_next_unread_pm() - assert return_value is None - assert mid_col_view.last_unread_pm is None - @pytest.mark.parametrize("key", keys_for_command("SEARCH_MESSAGES")) def test_keypress_focus_header(self, mid_col_view, mocker, key, widget_size): size = widget_size(mid_col_view) @@ -900,9 +916,10 @@ def test_keypress_NEXT_UNREAD_TOPIC_stream( ): size = widget_size(mid_col_view) mocker.patch(MIDCOLVIEW + ".focus_position") + mocker.patch.object(self.view, "message_view") mid_col_view.model.stream_dict = {1: {"name": "stream"}} - mid_col_view.model.get_next_unread_topic.return_value = (1, "topic") + mid_col_view.model.next_unread_topic_from_message_id.return_value = (1, "topic") return_value = mid_col_view.keypress(size, key) @@ -917,7 +934,8 @@ def test_keypress_NEXT_UNREAD_TOPIC_no_stream( ): size = widget_size(mid_col_view) mocker.patch(MIDCOLVIEW + ".focus_position") - mid_col_view.model.get_next_unread_topic.return_value = None + mocker.patch.object(self.view, "message_view") + mid_col_view.model.next_unread_topic_from_message_id.return_value = None return_value = mid_col_view.keypress(size, key) @@ -930,8 +948,9 @@ def test_keypress_NEXT_UNREAD_PM_stream( ): size = widget_size(mid_col_view) mocker.patch(MIDCOLVIEW + ".focus_position") - mocker.patch(MIDCOLVIEW + ".get_next_unread_pm", return_value=1) + mid_col_view.model.user_id_email_dict = {1: "EMAIL"} + mid_col_view.model.get_next_unread_pm.return_value = 1 mid_col_view.keypress(size, key) @@ -946,16 +965,17 @@ def test_keypress_NEXT_UNREAD_PM_no_pm( ): size = widget_size(mid_col_view) mocker.patch(MIDCOLVIEW + ".focus_position") - mocker.patch(MIDCOLVIEW + ".get_next_unread_pm", return_value=None) + mid_col_view.model.get_next_unread_pm.return_value = None return_value = mid_col_view.keypress(size, key) + assert return_value == key @pytest.mark.parametrize("key", keys_for_command("PRIVATE_MESSAGE")) def test_keypress_PRIVATE_MESSAGE(self, mid_col_view, mocker, key, widget_size): size = widget_size(mid_col_view) mocker.patch(MIDCOLVIEW + ".focus_position") - mocker.patch(MIDCOLVIEW + ".get_next_unread_pm", return_value=None) + mid_col_view.model.get_next_unread_pm.return_value = None mid_col_view.footer = mocker.Mock() return_value = mid_col_view.keypress(size, key) mid_col_view.footer.private_box_view.assert_called_once_with() @@ -969,7 +989,7 @@ def mock_external_classes(self, mocker): self.view = mocker.Mock() self.user_search = mocker.patch(VIEWS + ".PanelSearchBox") self.connect_signal = mocker.patch(VIEWS + ".urwid.connect_signal") - self.line_box = mocker.patch(VIEWS + ".urwid.LineBox") + self.pile = mocker.patch(VIEWS + ".urwid.Pile") self.thread = mocker.patch(VIEWS + ".threading") self.super = mocker.patch(VIEWS + ".urwid.Frame.__init__") self.view.model.unread_counts = { # Minimal, though an UnreadCounts @@ -992,7 +1012,7 @@ def test_init(self, right_col_view): assert right_col_view.search_lock == self.thread.Lock() self.super.assert_called_once_with( right_col_view.users_view(), - header=self.line_box(right_col_view.user_search), + header=self.pile(right_col_view.user_search), ) def test_update_user_list_editor_mode(self, mocker, right_col_view): @@ -1085,8 +1105,8 @@ def test_keypress_SEARCH_PEOPLE(self, right_col_view, mocker, key, widget_size): right_col_view.user_search ) - @pytest.mark.parametrize("key", keys_for_command("GO_BACK")) - def test_keypress_GO_BACK(self, right_col_view, mocker, key, widget_size): + @pytest.mark.parametrize("key", keys_for_command("CLEAR_SEARCH")) + def test_keypress_CLEAR_SEARCH(self, right_col_view, mocker, key, widget_size): size = widget_size(right_col_view) mocker.patch(VIEWS + ".UsersView") mocker.patch(VIEWS + ".RightColumnView.set_focus") diff --git a/tests/ui_tools/test_boxes.py b/tests/ui_tools/test_boxes.py index d9fb8b10a2..c4e89a2b58 100644 --- a/tests/ui_tools/test_boxes.py +++ b/tests/ui_tools/test_boxes.py @@ -7,7 +7,16 @@ from pytest_mock import MockerFixture from urwid import Widget -from zulipterminal.config.keys import keys_for_command, primary_key_for_command +from zulipterminal.api_types import ( + TYPING_STARTED_EXPIRY_PERIOD, + TYPING_STARTED_WAIT_PERIOD, + TYPING_STOPPED_WAIT_PERIOD, +) +from zulipterminal.config.keys import ( + keys_for_command, + primary_display_key_for_command, + primary_key_for_command, +) from zulipterminal.config.symbols import ( INVALID_MARKER, STREAM_MARKER_PRIVATE, @@ -15,8 +24,13 @@ STREAM_MARKER_WEB_PUBLIC, ) from zulipterminal.config.ui_mappings import StreamAccessType -from zulipterminal.helper import Index -from zulipterminal.ui_tools.boxes import PanelSearchBox, WriteBox, _MessageEditState +from zulipterminal.helper import Index, MinimalUserData +from zulipterminal.ui_tools.boxes import ( + MAX_MESSAGE_LENGTH_CONFIRMATION_POPUP, + PanelSearchBox, + WriteBox, + _MessageEditState, +) from zulipterminal.urwid_types import urwid_Size @@ -40,7 +54,7 @@ def write_box( user_groups_fixture: List[Dict[str, Any]], streams_fixture: List[Dict[str, Any]], unicode_emojis: "OrderedDict[str, Dict[str, Any]]", - user_dict: Dict[str, Dict[str, Any]], + user_dict: Dict[str, MinimalUserData], ) -> WriteBox: self.view.model.active_emoji_data = unicode_emojis self.view.model.all_emoji_names = list(unicode_emojis.keys()) @@ -50,6 +64,9 @@ def write_box( write_box.model.max_stream_name_length = 60 write_box.model.max_topic_length = 60 write_box.model.max_message_length = 10000 + write_box.model.typing_started_wait_period = TYPING_STARTED_WAIT_PERIOD + write_box.model.typing_stopped_wait_period = TYPING_STOPPED_WAIT_PERIOD + write_box.model.typing_started_expiry_period = TYPING_STARTED_EXPIRY_PERIOD write_box.model.user_group_names = [ groups["name"] for groups in user_groups_fixture ] @@ -96,22 +113,39 @@ def test_not_calling_typing_method_without_recipients( [ ("#**Stream 1>T", 0, True, "#**Stream 1>Topic 1**"), ("#**Stream 1>T", 1, True, "#**Stream 1>This is a topic**"), - ("#**Stream 1>T", 2, True, None), - ("#**Stream 1>T", -1, True, "#**Stream 1>This is a topic**"), - ("#**Stream 1>T", -2, True, "#**Stream 1>Topic 1**"), - ("#**Stream 1>T", -3, True, None), + ("#**Stream 1>T", 2, True, "#**Stream 1>Hello there!**"), + ("#**Stream 1>T", 3, True, "#**Stream 1>He-llo there!**"), + ("#**Stream 1>T", 4, True, "#**Stream 1>Hello t/here!**"), + ("#**Stream 1>T", 5, True, None), + ("#**Stream 1>T", -1, True, "#**Stream 1>Hello t/here!**"), + ("#**Stream 1>T", -2, True, "#**Stream 1>He-llo there!**"), + ("#**Stream 1>T", -3, True, "#**Stream 1>Hello there!**"), + ("#**Stream 1>T", -4, True, "#**Stream 1>This is a topic**"), + ("#**Stream 1>T", -5, True, "#**Stream 1>Topic 1**"), + ("#**Stream 1>T", -6, True, None), ("#**Stream 1>To", 0, True, "#**Stream 1>Topic 1**"), ("#**Stream 1>H", 0, True, "#**Stream 1>Hello there!**"), ("#**Stream 1>Hello ", 0, True, "#**Stream 1>Hello there!**"), ("#**Stream 1>", 0, True, "#**Stream 1>Topic 1**"), ("#**Stream 1>", 1, True, "#**Stream 1>This is a topic**"), - ("#**Stream 1>", -1, True, "#**Stream 1>Hello there!**"), - ("#**Stream 1>", -2, True, "#**Stream 1>This is a topic**"), + ("#**Stream 1>", 2, True, "#**Stream 1>Hello there!**"), + ("#**Stream 1>", 3, True, "#**Stream 1>He-llo there!**"), + ("#**Stream 1>", 4, True, "#**Stream 1>Hello t/here!**"), + ("#**Stream 1>", 5, True, "#**Stream 1>Hello from out-er_space!**"), + ("#**Stream 1>", 6, True, None), + ("#**Stream 1>", -1, True, "#**Stream 1>Hello from out-er_space!**"), + ("#**Stream 1>", -2, True, "#**Stream 1>Hello t/here!**"), + ("#**Stream 1>", -3, True, "#**Stream 1>He-llo there!**"), + ("#**Stream 1>", -4, True, "#**Stream 1>Hello there!**"), + ("#**Stream 1>", -5, True, "#**Stream 1>This is a topic**"), + ("#**Stream 1>", -6, True, "#**Stream 1>Topic 1**"), + ("#**Stream 1>", -7, True, None), # Fenced prefix ("#**Stream 1**>T", 0, True, "#**Stream 1>Topic 1**"), # Unfenced prefix ("#Stream 1>T", 0, True, "#**Stream 1>Topic 1**"), ("#Stream 1>T", 1, True, "#**Stream 1>This is a topic**"), + ("#Stream 1>T", 2, True, "#**Stream 1>Hello there!**"), # Invalid stream ("#**invalid stream>", 0, False, None), ("#**invalid stream**>", 0, False, None), @@ -206,8 +240,8 @@ def test_not_calling_send_private_message_without_recipients( assert not write_box.model.send_private_message.called - @pytest.mark.parametrize("key", keys_for_command("GO_BACK")) - def test__compose_attributes_reset_for_private_compose( + @pytest.mark.parametrize("key", keys_for_command("EXIT_COMPOSE")) + def test__compose_attributes_reset_for_private_compose__no_popup( self, key: str, mocker: MockerFixture, @@ -218,17 +252,41 @@ def test__compose_attributes_reset_for_private_compose( mocker.patch("urwid.connect_signal") write_box.model.user_id_email_dict = user_id_email_dict write_box.private_box_view(recipient_user_ids=[11]) - write_box.msg_write_box.edit_text = "random text" + + write_box.msg_write_box.edit_text = "." * ( + MAX_MESSAGE_LENGTH_CONFIRMATION_POPUP - 1 + ) size = widget_size(write_box) write_box.keypress(size, key) + write_box.view.controller.exit_compose_confirmation_popup.assert_not_called() assert write_box.to_write_box is None assert write_box.msg_write_box.edit_text == "" assert write_box.compose_box_status == "closed" - @pytest.mark.parametrize("key", keys_for_command("GO_BACK")) - def test__compose_attributes_reset_for_stream_compose( + @pytest.mark.parametrize("key", keys_for_command("EXIT_COMPOSE")) + def test__compose_attributes_reset_for_private_compose__popup( + self, + key: str, + mocker: MockerFixture, + write_box: WriteBox, + widget_size: Callable[[Widget], urwid_Size], + user_id_email_dict: Dict[int, str], + ) -> None: + mocker.patch("urwid.connect_signal") + write_box.model.user_id_email_dict = user_id_email_dict + write_box.private_box_view(recipient_user_ids=[11]) + + write_box.msg_write_box.edit_text = "." * MAX_MESSAGE_LENGTH_CONFIRMATION_POPUP + + size = widget_size(write_box) + write_box.keypress(size, key) + + write_box.view.controller.exit_compose_confirmation_popup.assert_called_once() + + @pytest.mark.parametrize("key", keys_for_command("EXIT_COMPOSE")) + def test__compose_attributes_reset_for_stream_compose__no_popup( self, key: str, mocker: MockerFixture, @@ -237,15 +295,37 @@ def test__compose_attributes_reset_for_stream_compose( ) -> None: mocker.patch(WRITEBOX + "._set_stream_write_box_style") write_box.stream_box_view(stream_id=1) - write_box.msg_write_box.edit_text = "random text" + + write_box.msg_write_box.edit_text = "." * ( + MAX_MESSAGE_LENGTH_CONFIRMATION_POPUP - 1 + ) size = widget_size(write_box) write_box.keypress(size, key) + write_box.view.controller.exit_compose_confirmation_popup.assert_not_called() assert write_box.stream_id is None assert write_box.msg_write_box.edit_text == "" assert write_box.compose_box_status == "closed" + @pytest.mark.parametrize("key", keys_for_command("EXIT_COMPOSE")) + def test__compose_attributes_reset_for_stream_compose__popup( + self, + key: str, + mocker: MockerFixture, + write_box: WriteBox, + widget_size: Callable[[Widget], urwid_Size], + ) -> None: + mocker.patch(WRITEBOX + "._set_stream_write_box_style") + write_box.stream_box_view(stream_id=1) + + write_box.msg_write_box.edit_text = "." * MAX_MESSAGE_LENGTH_CONFIRMATION_POPUP + + size = widget_size(write_box) + write_box.keypress(size, key) + + write_box.view.controller.exit_compose_confirmation_popup.assert_called_once_with() + @pytest.mark.parametrize( ["raw_recipients", "tidied_recipients"], [ @@ -354,9 +434,12 @@ def test_footer_notification_on_invalid_recipients( expected_lines = [ "Invalid recipient(s) - " + invalid_recipients, " - Use ", - ("footer_contrast", primary_key_for_command("AUTOCOMPLETE")), + ("footer_contrast", primary_display_key_for_command("AUTOCOMPLETE")), " or ", - ("footer_contrast", primary_key_for_command("AUTOCOMPLETE_REVERSE")), + ( + "footer_contrast", + primary_display_key_for_command("AUTOCOMPLETE_REVERSE"), + ), " to autocomplete.", ] @@ -1306,12 +1389,30 @@ def test__stream_box_autocomplete_with_spaces( @pytest.mark.parametrize( "text, matching_topics", [ - ("", ["Topic 1", "This is a topic", "Hello there!"]), - ("Th", ["This is a topic"]), + ( + "", + [ + "Topic 1", + "This is a topic", + "Hello there!", + "He-llo there!", + "Hello t/here!", + "Hello from out-er_space!", + ], + ), + ("Th", ["This is a topic", "Hello there!", "He-llo there!"]), + ("ll", ["He-llo there!"]), + ("her", ["Hello t/here!"]), + ("er", ["Hello from out-er_space!"]), + ("spa", ["Hello from out-er_space!"]), ], ids=[ "no_search_text", "single_word_search_text", + "split_in_first_word", + "split_in_second_word", + "first_split_in_third_word", + "second_split_in_third_word", ], ) def test__topic_box_autocomplete( @@ -1458,38 +1559,59 @@ def test_keypress_SEND_MESSAGE_no_topic( ) @pytest.mark.parametrize( - "key, current_typeahead_mode, expected_typeahead_mode, expect_footer_was_reset", + "key, current_typeahead_mode, expected_typeahead_mode", [ - # footer does not reset - (primary_key_for_command("AUTOCOMPLETE"), False, False, False), - (primary_key_for_command("AUTOCOMPLETE_REVERSE"), False, False, False), - (primary_key_for_command("AUTOCOMPLETE"), True, True, False), - (primary_key_for_command("AUTOCOMPLETE_REVERSE"), True, True, False), - # footer resets - (primary_key_for_command("GO_BACK"), True, False, True), - ("space", True, False, True), - ("k", True, False, True), + (primary_key_for_command("AUTOCOMPLETE"), False, False), + (primary_key_for_command("AUTOCOMPLETE_REVERSE"), False, False), + (primary_key_for_command("AUTOCOMPLETE"), True, True), + (primary_key_for_command("AUTOCOMPLETE_REVERSE"), True, True), ], ) - def test_keypress_typeahead_mode_autocomplete_key( + def test__keypress_typeahead_mode_autocomplete_key_footer_no_reset( self, + mocker: MockerFixture, write_box: WriteBox, widget_size: Callable[[Widget], urwid_Size], current_typeahead_mode: bool, expected_typeahead_mode: bool, - expect_footer_was_reset: bool, key: str, ) -> None: + write_box.msg_write_box = mocker.Mock(edit_text="") write_box.is_in_typeahead_mode = current_typeahead_mode size = widget_size(write_box) write_box.keypress(size, key) assert write_box.is_in_typeahead_mode == expected_typeahead_mode - if expect_footer_was_reset: - self.view.set_footer_text.assert_called_once_with() - else: - self.view.set_footer_text.assert_not_called() + assert not self.view.set_footer_text.called + + @pytest.mark.parametrize( + "key, current_typeahead_mode, expected_typeahead_mode", + [ + (primary_key_for_command("EXIT_COMPOSE"), True, False), + ("space", True, False), + ("k", True, False), + ], + ) + def test__keypress_typeahead_mode_autocomplete_key_footer_reset( + self, + mocker: MockerFixture, + write_box: WriteBox, + widget_size: Callable[[Widget], urwid_Size], + current_typeahead_mode: bool, + expected_typeahead_mode: bool, + key: str, + ) -> None: + write_box.msg_write_box = mocker.Mock(edit_text="") + write_box.is_in_typeahead_mode = current_typeahead_mode + size = widget_size(write_box) + + write_box.keypress(size, key) + + assert write_box.is_in_typeahead_mode == expected_typeahead_mode + + # We may prefer called-once in future, but the key part is that we do reset + assert self.view.set_footer_text.called @pytest.mark.parametrize( [ @@ -1717,8 +1839,8 @@ class TestPanelSearchBox: @pytest.fixture def panel_search_box(self, mocker: MockerFixture) -> PanelSearchBox: - # X is the return from keys_for_command("UNTESTED_TOKEN") - mocker.patch(MODULE + ".keys_for_command", return_value="X") + # X is the return from display_keys_for_command("UNTESTED_TOKEN") + mocker.patch(MODULE + ".display_keys_for_command", return_value="X") panel_view = mocker.Mock() update_func = mocker.Mock() return PanelSearchBox(panel_view, "UNTESTED_TOKEN", update_func) @@ -1769,7 +1891,7 @@ def test_valid_char( @pytest.mark.parametrize( "log, expect_body_focus_set", [([], False), (["SOMETHING"], True)] ) - @pytest.mark.parametrize("enter_key", keys_for_command("ENTER")) + @pytest.mark.parametrize("enter_key", keys_for_command("EXECUTE_SEARCH")) def test_keypress_ENTER( self, panel_search_box: PanelSearchBox, @@ -1808,8 +1930,8 @@ def test_keypress_ENTER( panel_view.set_focus.assert_not_called() panel_view.body.set_focus.assert_not_called() - @pytest.mark.parametrize("back_key", keys_for_command("GO_BACK")) - def test_keypress_GO_BACK( + @pytest.mark.parametrize("back_key", keys_for_command("CLEAR_SEARCH")) + def test_keypress_CLEAR_SEARCH( self, panel_search_box: PanelSearchBox, back_key: str, diff --git a/tests/ui_tools/test_buttons.py b/tests/ui_tools/test_buttons.py index b29e88af89..adc2faa127 100644 --- a/tests/ui_tools/test_buttons.py +++ b/tests/ui_tools/test_buttons.py @@ -262,7 +262,7 @@ def test_keypress_TOGGLE_MUTE_STREAM( class TestUserButton: # FIXME Place this in a general test of a derived class? - @pytest.mark.parametrize("enter_key", keys_for_command("ENTER")) + @pytest.mark.parametrize("enter_key", keys_for_command("ACTIVATE_BUTTON")) def test_activate_called_once_on_keypress( self, mocker: MockerFixture, @@ -358,7 +358,7 @@ def test_init_calls_top_button( assert emoji_button.emoji_name == emoji_unit[0] assert emoji_button.reaction_count == count - @pytest.mark.parametrize("key", keys_for_command("ENTER")) + @pytest.mark.parametrize("key", keys_for_command("ACTIVATE_BUTTON")) @pytest.mark.parametrize( "emoji, has_user_reacted, is_selected_final, expected_reaction_count", [ @@ -653,70 +653,109 @@ def test__decode_message_id( @pytest.mark.parametrize( "link, expected_parsed_link", [ - ( - SERVER_URL + "/#narrow/stream/1-Stream-1", + case( + "/#narrow/stream/1-Stream-1", ParsedNarrowLink( narrow="stream", stream=DecodedStream(stream_id=1, stream_name=None) ), + id="modern_stream_narrow_link", ), - ( - SERVER_URL + "/#narrow/stream/Stream.201", + case( + "/#narrow/stream/Stream.201", ParsedNarrowLink( narrow="stream", stream=DecodedStream(stream_id=None, stream_name="Stream 1"), ), + id="deprecated_stream_narrow_link", ), - ( - SERVER_URL + "/#narrow/stream/1-Stream-1/topic/foo.20bar", + case( + "/#narrow/stream/1-Stream-1/topic/foo.20bar", ParsedNarrowLink( narrow="stream:topic", topic_name="foo bar", stream=DecodedStream(stream_id=1, stream_name=None), ), + id="topic_narrow_link", ), - ( - SERVER_URL + "/#narrow/stream/1-Stream-1/near/1", + case( + "/#narrow/stream/1-Stream-1/subject/foo.20bar", + ParsedNarrowLink( + narrow="stream:topic", + topic_name="foo bar", + stream=DecodedStream(stream_id=1, stream_name=None), + ), + id="subject_narrow_link", + ), + case( + "/#narrow/stream/1-Stream-1/near/987", ParsedNarrowLink( narrow="stream:near", - message_id=1, + message_id=987, stream=DecodedStream(stream_id=1, stream_name=None), ), + id="stream_near_narrow_link", ), - ( - SERVER_URL + "/#narrow/stream/1-Stream-1/topic/foo/near/1", + case( + "/#narrow/stream/1-Stream-1/topic/foo/near/789", ParsedNarrowLink( narrow="stream:topic:near", topic_name="foo", - message_id=1, + message_id=789, stream=DecodedStream(stream_id=1, stream_name=None), ), + id="topic_near_narrow_link", ), - (SERVER_URL + "/#narrow/foo", ParsedNarrowLink()), - (SERVER_URL + "/#narrow/stream/", ParsedNarrowLink()), - (SERVER_URL + "/#narrow/stream/1-Stream-1/topic/", ParsedNarrowLink()), - (SERVER_URL + "/#narrow/stream/1-Stream-1//near/", ParsedNarrowLink()), - ( - SERVER_URL + "/#narrow/stream/1-Stream-1/topic/foo/near/", + case( + "/#narrow/stream/1-Stream-1/subject/foo/near/654", + ParsedNarrowLink( + narrow="stream:topic:near", + topic_name="foo", + message_id=654, + stream=DecodedStream(stream_id=1, stream_name=None), + ), + id="subject_near_narrow_link", + ), + case( + "/#narrow/foo", ParsedNarrowLink(), + id="invalid_narrow_link_1", + ), + case( + "/#narrow/stream/", + ParsedNarrowLink(), + id="invalid_narrow_link_2", + ), + case( + "/#narrow/stream/1-Stream-1/topic/", + ParsedNarrowLink(), + id="invalid_narrow_link_3", + ), + case( + "/#narrow/stream/1-Stream-1/subject/", + ParsedNarrowLink(), + id="invalid_narrow_link_4", + ), + case( + "/#narrow/stream/1-Stream-1//near/", + ParsedNarrowLink(), + id="invalid_narrow_link_5", + ), + case( + "/#narrow/stream/1-Stream-1/topic/foo/near/", + ParsedNarrowLink(), + id="invalid_narrow_link_6", + ), + case( + "/#narrow/stream/1-Stream-1/subject/foo/near/", + ParsedNarrowLink(), + id="invalid_narrow_link_7", ), - ], - ids=[ - "modern_stream_narrow_link", - "deprecated_stream_narrow_link", - "topic_narrow_link", - "stream_near_narrow_link", - "topic_near_narrow_link", - "invalid_narrow_link_1", - "invalid_narrow_link_2", - "invalid_narrow_link_3", - "invalid_narrow_link_4", - "invalid_narrow_link_5", ], ) def test__parse_narrow_link( self, link: str, expected_parsed_link: ParsedNarrowLink ) -> None: - return_value = MessageLinkButton._parse_narrow_link(link) + return_value = MessageLinkButton._parse_narrow_link(SERVER_URL + link) assert return_value == expected_parsed_link diff --git a/tests/ui_tools/test_messages.py b/tests/ui_tools/test_messages.py index e0bbe18d33..09a68bc89f 100644 --- a/tests/ui_tools/test_messages.py +++ b/tests/ui_tools/test_messages.py @@ -1,5 +1,6 @@ from collections import OrderedDict, defaultdict from datetime import date +from unittest.mock import patch import pytest import pytz @@ -7,10 +8,15 @@ from pytest import param as case from urwid import Columns, Divider, Padding, Text -from zulipterminal.config.keys import keys_for_command +from zulipterminal.config.keys import keys_for_command, primary_key_for_command from zulipterminal.config.symbols import ( + ALL_MESSAGES_MARKER, + DIRECT_MESSAGE_MARKER, + MENTIONED_MESSAGES_MARKER, QUOTED_TEXT_MARKER, + STARRED_MESSAGES_MARKER, STATUS_INACTIVE, + STREAM_MARKER_PUBLIC, STREAM_TOPIC_SEPARATOR, TIME_MENTION_MARKER, ) @@ -28,6 +34,7 @@ class TestMessageBox: def mock_external_classes(self, mocker, initial_index): self.model = mocker.MagicMock() self.model.index = initial_index + self.model.stream_access_type.return_value = "public" @pytest.mark.parametrize( "message_type, set_fields", @@ -108,6 +115,11 @@ def test_private_message_to_self(self, mocker): [("msg_mention", "@A Group")], id="group-mention", ), + case( + '@topic', + [("msg_mention", "@topic")], + id="topic-mention", + ), case("some code", [("pygments:w", "some code")], id="inline-code"), case( '
' @@ -893,54 +905,117 @@ def test_main_view_generates_PM_header( @pytest.mark.parametrize( "msg_narrow, msg_type, assert_header_bar, assert_search_bar", [ - ([], 0, f"PTEST {STREAM_TOPIC_SEPARATOR} ", "All messages"), - ([], 1, "You and ", "All messages"), - ([], 2, "You and ", "All messages"), + ( + [], + 0, + f" {STREAM_MARKER_PUBLIC} PTEST {STREAM_TOPIC_SEPARATOR} ", + f" {ALL_MESSAGES_MARKER} All messages ", + ), + ( + [], + 1, + f" {DIRECT_MESSAGE_MARKER} You and ", + f" {ALL_MESSAGES_MARKER} All messages ", + ), + ( + [], + 2, + f" {DIRECT_MESSAGE_MARKER} You and ", + f" {ALL_MESSAGES_MARKER} All messages ", + ), ( [["stream", "PTEST"]], 0, - f"PTEST {STREAM_TOPIC_SEPARATOR} ", - ("bar", [("s#bd6", "PTEST")]), + f" {STREAM_MARKER_PUBLIC} PTEST {STREAM_TOPIC_SEPARATOR} ", + ("bar", ("s#bd6", f" {STREAM_MARKER_PUBLIC} PTEST ")), ), ( [["stream", "PTEST"], ["topic", "b"]], 0, - f"PTEST {STREAM_TOPIC_SEPARATOR}", - ("bar", [("s#bd6", "PTEST"), ("s#bd6", ": topic narrow")]), + f" {STREAM_MARKER_PUBLIC} PTEST {STREAM_TOPIC_SEPARATOR}", + ( + "bar", + ("s#bd6", f" {STREAM_MARKER_PUBLIC} PTEST: topic narrow "), + ), + ), + ( + [["is", "private"]], + 1, + f" {DIRECT_MESSAGE_MARKER} You and ", + f" {DIRECT_MESSAGE_MARKER} All direct messages ", ), - ([["is", "private"]], 1, "You and ", "All direct messages"), - ([["is", "private"]], 2, "You and ", "All direct messages"), ( - [["pm_with", "boo@zulip.com"]], + [["is", "private"]], + 2, + f" {DIRECT_MESSAGE_MARKER} You and ", + f" {DIRECT_MESSAGE_MARKER} All direct messages ", + ), + ( + [["pm-with", "boo@zulip.com"]], 1, - "You and ", - "Direct message conversation", + f" {DIRECT_MESSAGE_MARKER} You and ", + f" {DIRECT_MESSAGE_MARKER} Direct message conversation ", ), ( - [["pm_with", "boo@zulip.com, bar@zulip.com"]], + [["pm-with", "boo@zulip.com, bar@zulip.com"]], 2, - "You and ", - "Group direct message conversation", + f" {DIRECT_MESSAGE_MARKER} You and ", + f" {DIRECT_MESSAGE_MARKER} Group direct message conversation ", ), ( [["is", "starred"]], 0, - f"PTEST {STREAM_TOPIC_SEPARATOR} ", - "Starred messages", + f" {STREAM_MARKER_PUBLIC} PTEST {STREAM_TOPIC_SEPARATOR} ", + f" {STARRED_MESSAGES_MARKER} Starred messages ", + ), + ( + [["is", "starred"]], + 1, + f" {DIRECT_MESSAGE_MARKER} You and ", + f" {STARRED_MESSAGES_MARKER} Starred messages ", + ), + ( + [["is", "starred"]], + 2, + f" {DIRECT_MESSAGE_MARKER} You and ", + f" {STARRED_MESSAGES_MARKER} Starred messages ", + ), + ( + [["is", "starred"], ["search", "FOO"]], + 1, + f" {DIRECT_MESSAGE_MARKER} You and ", + f" {STARRED_MESSAGES_MARKER} Starred messages ", ), - ([["is", "starred"]], 1, "You and ", "Starred messages"), - ([["is", "starred"]], 2, "You and ", "Starred messages"), - ([["is", "starred"], ["search", "FOO"]], 1, "You and ", "Starred messages"), ( [["search", "FOO"]], 0, - f"PTEST {STREAM_TOPIC_SEPARATOR} ", - "All messages", + f" {STREAM_MARKER_PUBLIC} PTEST {STREAM_TOPIC_SEPARATOR} ", + f" {ALL_MESSAGES_MARKER} All messages ", + ), + ( + [["is", "mentioned"]], + 0, + f" {STREAM_MARKER_PUBLIC} PTEST {STREAM_TOPIC_SEPARATOR} ", + f" {MENTIONED_MESSAGES_MARKER} Mentions ", + ), + ( + [["is", "mentioned"]], + 1, + f" {DIRECT_MESSAGE_MARKER} You and ", + f" {MENTIONED_MESSAGES_MARKER} Mentions ", + ), + ( + [["is", "mentioned"]], + 2, + f" {DIRECT_MESSAGE_MARKER} You and ", + f" {MENTIONED_MESSAGES_MARKER} Mentions ", + ), + ( + [["is", "mentioned"], ["search", "FOO"]], + 1, + f" {DIRECT_MESSAGE_MARKER} You and ", + f" {MENTIONED_MESSAGES_MARKER} Mentions ", ), - ([["is", "mentioned"]], 0, f"PTEST {STREAM_TOPIC_SEPARATOR} ", "Mentions"), - ([["is", "mentioned"]], 1, "You and ", "Mentions"), - ([["is", "mentioned"]], 2, "You and ", "Mentions"), - ([["is", "mentioned"], ["search", "FOO"]], 1, "You and ", "Mentions"), ], ) def test_msg_generates_search_and_header_bar( @@ -962,7 +1037,7 @@ def test_msg_generates_search_and_header_bar( current_message = messages[msg_type] msg_box = MessageBox(current_message, self.model, messages[0]) search_bar = msg_box.top_search_bar() - header_bar = msg_box.top_header_bar(msg_box) + header_bar = msg_box.recipient_header() assert header_bar[0].text.startswith(assert_header_bar) assert search_bar.text_to_fill == assert_search_bar @@ -1129,7 +1204,7 @@ def test_update_message_author_status( ([["is", "starred"]], False), ([["is", "mentioned"]], False), ([["is", "private"]], False), - ([["pm_with", "notification-bot@zulip.com"]], False), + ([["pm-with", "notification-bot@zulip.com"]], False), ], ids=[ "all_messages_narrow", @@ -1227,7 +1302,16 @@ def test_keypress_STREAM_MESSAGE( {"stream": True, "private": True}, {"stream": True, "private": True}, {"stream": None, "private": None}, - id="no_msg_body_edit_limit", + id="no_msg_body_edit_limit:ZFL<138", + ), + case( + {"sender_id": 1, "timestamp": 1, "subject": "test"}, + True, + None, + {"stream": True, "private": True}, + {"stream": True, "private": True}, + {"stream": None, "private": None}, + id="no_msg_body_edit_limit:ZFL>=138", ), case( {"sender_id": 1, "timestamp": 1, "subject": "(no topic)"}, @@ -1278,7 +1362,16 @@ def test_keypress_STREAM_MESSAGE( {"stream": True, "private": True}, {"stream": True, "private": True}, {"stream": None, "private": None}, - id="no_msg_body_edit_limit_with_no_topic", + id="no_msg_body_edit_limit_with_no_topic:ZFL<138", + ), + case( + {"sender_id": 1, "timestamp": 45, "subject": "(no topic)"}, + True, + None, + {"stream": True, "private": True}, + {"stream": True, "private": True}, + {"stream": None, "private": None}, + id="no_msg_body_edit_limit_with_no_topic:ZFL>=138", ), ], ) @@ -1505,60 +1598,6 @@ def test_transform_content(self, mocker, raw_html, expected_content): @pytest.mark.parametrize( "to_vary_in_each_message, expected_text, expected_attributes", [ - case( - { - "reactions": [ - { - "emoji_name": "thumbs_up", - "emoji_code": "1f44d", - "user": { - "email": "iago@zulip.com", - "full_name": "Iago", - "id": 5, - }, - "reaction_type": "unicode_emoji", - }, - { - "emoji_name": "zulip", - "emoji_code": "zulip", - "user": { - "email": "iago@zulip.com", - "full_name": "Iago", - "id": 5, - }, - "reaction_type": "zulip_extra_emoji", - }, - { - "emoji_name": "zulip", - "emoji_code": "zulip", - "user": { - "email": "AARON@zulip.com", - "full_name": "aaron", - "id": 1, - }, - "reaction_type": "zulip_extra_emoji", - }, - { - "emoji_name": "heart", - "emoji_code": "2764", - "user": { - "email": "iago@zulip.com", - "full_name": "Iago", - "id": 5, - }, - "reaction_type": "unicode_emoji", - }, - ], - }, - " :thumbs_up: 1 :zulip: 2 :heart: 1 ", - [ - ("reaction", 15), - (None, 1), - ("reaction_mine", 11), - (None, 1), - ("reaction", 11), - ], - ), case( { "reactions": [ @@ -1609,31 +1648,19 @@ def test_transform_content(self, mocker, raw_html, expected_content): { "emoji_name": "zulip", "emoji_code": "zulip", - "user": { - "email": "iago@zulip.com", - "full_name": "Iago", - "id": 5, - }, + "user_id": 5, "reaction_type": "unicode_emoji", }, { "emoji_name": "zulip", "emoji_code": "zulip", - "user": { - "email": "AARON@zulip.com", - "full_name": "aaron", - "id": 1, - }, + "user_id": 1, "reaction_type": "zulip_extra_emoji", }, { "emoji_name": "zulip", "emoji_code": "zulip", - "user": { - "email": "shivam@zulip.com", - "full_name": "Shivam", - "id": 6, - }, + "user_id": 6, "reaction_type": "unicode_emoji", }, ], @@ -1649,31 +1676,19 @@ def test_transform_content(self, mocker, raw_html, expected_content): { "emoji_name": "heart", "emoji_code": "2764", - "user": { - "email": "iago@zulip.com", - "full_name": "Iago", - "id": 5, - }, + "user_id": 5, "reaction_type": "unicode_emoji", }, { "emoji_name": "zulip", "emoji_code": "zulip", - "user": { - "email": "AARON@zulip.com", - "full_name": "aaron", - "id": 1, - }, + "user_id": 1, "reaction_type": "zulip_extra_emoji", }, { "emoji_name": "zulip", "emoji_code": "zulip", - "user": { - "email": "shivam@zulip.com", - "full_name": "Shivam", - "id": 6, - }, + "user_id": 6, "reaction_type": "unicode_emoji", }, ], @@ -1729,10 +1744,26 @@ def test_reactions_view( msg_box = MessageBox(varied_message, self.model, None) reactions = to_vary_in_each_message["reactions"] - reactions_view = msg_box.reactions_view(reactions) + mock_all_users_by_id = { + 1: {"full_name": "aaron"}, + 5: {"full_name": "Iago"}, + 6: {"full_name": "Shivam"}, + } + + def mock_get_user_id(reaction): + if "user_id" in reaction: + return reaction["user_id"] + return reaction["user"]["id"] + + with patch.object( + self.model, + "get_user_id_from_reaction", + side_effect=mock_get_user_id, + ), patch.object(self.model, "_all_users_by_id", mock_all_users_by_id): + reactions_view = msg_box.reactions_view(reactions) - assert reactions_view.original_widget.text == expected_text - assert reactions_view.original_widget.attrib == expected_attributes + assert reactions_view.original_widget.text == expected_text + assert reactions_view.original_widget.attrib == expected_attributes @pytest.mark.parametrize( "message_links, expected_text, expected_attrib, expected_footlinks_width", @@ -1869,11 +1900,14 @@ def test_footlinks_limit(self, maximum_footlinks, expected_instance): assert isinstance(footlinks, expected_instance) @pytest.mark.parametrize( - "key", keys_for_command("ENTER"), ids=lambda param: f"left_click-key:{param}" + "key", + keys_for_command("ACTIVATE_BUTTON"), + ids=lambda param: f"left_click-key:{param}", ) def test_mouse_event_left_click( self, mocker, msg_box, key, widget_size, compose_box_is_open ): + expected_keypress = primary_key_for_command("ACTIVATE_BUTTON") size = widget_size(msg_box) col = 1 row = 1 @@ -1887,4 +1921,4 @@ def test_mouse_event_left_click( if compose_box_is_open: msg_box.keypress.assert_not_called() else: - msg_box.keypress.assert_called_once_with(size, key) + msg_box.keypress.assert_called_once_with(size, expected_keypress) diff --git a/tests/ui_tools/test_popups.py b/tests/ui_tools/test_popups.py index 3205a13d52..47b3ff199c 100644 --- a/tests/ui_tools/test_popups.py +++ b/tests/ui_tools/test_popups.py @@ -1,5 +1,6 @@ from collections import OrderedDict from typing import Any, Callable, Dict, List, Optional, Tuple +from unittest.mock import patch import pytest from pytest import param as case @@ -9,7 +10,7 @@ from zulipterminal.api_types import Message from zulipterminal.config.keys import is_command_key, keys_for_command from zulipterminal.config.ui_mappings import EDIT_MODE_CAPTIONS -from zulipterminal.helper import TidiedUserInfo +from zulipterminal.helper import CustomProfileData, TidiedUserInfo from zulipterminal.ui_tools.messages import MessageBox from zulipterminal.ui_tools.views import ( AboutView, @@ -78,8 +79,8 @@ def test_exit_popup_no( self.callback.assert_not_called() assert self.controller.exit_popup.called - @pytest.mark.parametrize("key", keys_for_command("GO_BACK")) - def test_exit_popup_GO_BACK( + @pytest.mark.parametrize("key", keys_for_command("EXIT_POPUP")) + def test_exit_popup_EXIT_POPUP( self, popup_view: PopUpConfirmationView, key: str, @@ -133,8 +134,8 @@ def test_init(self, mocker: MockerFixture) -> None: self.pop_up_view.body, header=mocker.ANY, footer=mocker.ANY ) - @pytest.mark.parametrize("key", keys_for_command("GO_BACK")) - def test_keypress_GO_BACK( + @pytest.mark.parametrize("key", keys_for_command("EXIT_POPUP")) + def test_keypress_EXIT_POPUP( self, key: str, widget_size: Callable[[Widget], urwid_Size], @@ -187,9 +188,14 @@ def mock_external_classes(self, mocker: MockerFixture) -> None: mocker.patch.object( self.controller, "maximum_popup_dimensions", return_value=(64, 64) ) - mocker.patch(LISTWALKER, return_value=[]) server_version, server_feature_level = MINIMUM_SUPPORTED_SERVER_VERSION + # FIXME: Since we don't test on WSL explicitly, for now + # treat PLATFORM as WSL in order for it to be supported + mocker.patch(MODULE + ".PLATFORM", "WSL") + + mocker.patch(MODULE + ".detected_python_in_full", lambda: "[Python version]") + self.about_view = AboutView( self.controller, "About", @@ -201,10 +207,12 @@ def mock_external_classes(self, mocker: MockerFixture) -> None: notify_enabled=False, autohide_enabled=False, maximum_footlinks=3, + exit_confirmation_enabled=False, + transparency_enabled=False, ) @pytest.mark.parametrize( - "key", {*keys_for_command("GO_BACK"), *keys_for_command("ABOUT")} + "key", {*keys_for_command("EXIT_POPUP"), *keys_for_command("ABOUT")} ) def test_keypress_exit_popup( self, key: str, widget_size: Callable[[Widget], urwid_Size] @@ -213,6 +221,14 @@ def test_keypress_exit_popup( self.about_view.keypress(size, key) assert self.controller.exit_popup.called + @pytest.mark.parametrize("key", {*keys_for_command("COPY_ABOUT_INFO")}) + def test_keypress_copy_info( + self, key: str, widget_size: Callable[[Widget], urwid_Size] + ) -> None: + size = widget_size(self.about_view) + self.about_view.keypress(size, key) + assert self.controller.copy_to_clipboard.called + def test_keypress_exit_popup_invalid_key( self, widget_size: Callable[[Widget], urwid_Size] ) -> None: @@ -222,7 +238,7 @@ def test_keypress_exit_popup_invalid_key( assert not self.controller.exit_popup.called def test_feature_level_content( - self, mocker: MockerFixture, zulip_version: Tuple[str, Optional[int]] + self, mocker: MockerFixture, zulip_version: Tuple[str, int] ) -> None: self.controller = mocker.Mock() mocker.patch.object( @@ -242,12 +258,51 @@ def test_feature_level_content( notify_enabled=False, autohide_enabled=False, maximum_footlinks=3, + exit_confirmation_enabled=False, + transparency_enabled=False, ) assert len(about_view.feature_level_content) == ( 1 if server_feature_level else 0 ) + def test_categories(self) -> None: + categories = [ + widget.text + for widget in self.about_view.log + if isinstance(widget, Text) + and len(widget.attrib) + and "popup_category" in widget.attrib[0][0] + ] + assert categories == [ + "Application", + "Server", + "Application Configuration", + "Detected Environment", + "Copy information to clipboard [c]", + ] + + def test_copied_content(self) -> None: + expected_output = f"""#### Application +Zulip Terminal: {ZT_VERSION} + +#### Server +Version: {MINIMUM_SUPPORTED_SERVER_VERSION[0]} + +#### Application Configuration +Theme: zt_dark +Autohide: disabled +Maximum footlinks: 3 +Color depth: 256 +Notifications: disabled +Exit confirmation: disabled +Transparency: disabled + +#### Detected Environment +Platform: WSL +Python: [Python version]""" + assert self.about_view.copy_info == expected_output + class TestUserInfoView: @pytest.fixture(autouse=True) @@ -271,8 +326,17 @@ def mock_external_classes( return_value="Tue Mar 13 10:55 AM", ) + mocked_user_name_from_id = { + 11: "Human 1", + 12: "Human 2", + 13: "Human 3", + } + self.controller.model.user_name_from_id = mocker.Mock( + side_effect=lambda param: mocked_user_name_from_id.get(param, "(No name)") + ) + self.user_info_view = UserInfoView( - self.controller, 10000, "User Info (up/down scrolls)" + self.controller, 10000, "User Info (up/down scrolls)", "USER_INFO" ) @pytest.mark.parametrize( @@ -334,29 +398,80 @@ def mock_external_classes( ) def test__fetch_user_data( self, - mocker: MockerFixture, to_vary_in_each_user: Dict[str, Any], expected_key: str, expected_value: Optional[str], ) -> None: data = dict(self.user_data, **to_vary_in_each_user) - mocker.patch.object(self.controller.model, "get_user_info", return_value=data) + self.controller.model.get_user_info.return_value = data - display_data = self.user_info_view._fetch_user_data(self.controller, 1) + display_data, custom_profile_data = self.user_info_view._fetch_user_data( + self.controller, 1 + ) assert display_data.get(expected_key, None) == expected_value + @pytest.mark.parametrize( + [ + "to_vary_in_each_user", + "expected_value", + ], + [ + case( + [], + {}, + id="user_has_no_custom_profile_data", + ), + case( + [ + { + "label": "Biography", + "value": "Simplicity", + "type": 2, + "order": 2, + }, + { + "label": "Mentor", + "value": [11, 12], + "type": 6, + "order": 7, + }, + ], + {"Biography": "Simplicity", "Mentor": "Human 1, Human 2"}, + id="user_has_custom_profile_data", + ), + ], + ) + def test__fetch_user_data__custom_profile_data( + self, + to_vary_in_each_user: List[CustomProfileData], + expected_value: Dict[str, str], + ) -> None: + data = dict(self.user_data) + data["custom_profile_data"] = to_vary_in_each_user + + self.controller.model.get_user_info.return_value = data + + display_data, custom_profile_data = self.user_info_view._fetch_user_data( + self.controller, 1 + ) + + assert custom_profile_data == expected_value + def test__fetch_user_data_USER_NOT_FOUND(self, mocker: MockerFixture) -> None: mocker.patch.object(self.controller.model, "get_user_info", return_value=dict()) - display_data = self.user_info_view._fetch_user_data(self.controller, 1) + display_data, custom_profile_data = self.user_info_view._fetch_user_data( + self.controller, 1 + ) assert display_data["Name"] == "(Unavailable)" assert display_data["Error"] == "User data not found" + assert custom_profile_data == {} @pytest.mark.parametrize( - "key", {*keys_for_command("GO_BACK"), *keys_for_command("USER_INFO")} + "key", {*keys_for_command("EXIT_POPUP"), *keys_for_command("USER_INFO")} ) def test_keypress_exit_popup( self, key: str, widget_size: Callable[[Widget], urwid_Size] @@ -429,7 +544,7 @@ def test_keypress_exit_popup_invalid_key( "key", { *keys_for_command("FULL_RENDERED_MESSAGE"), - *keys_for_command("GO_BACK"), + *keys_for_command("EXIT_POPUP"), }, ) def test_keypress_show_msg_info( @@ -505,7 +620,7 @@ def test_keypress_exit_popup_invalid_key( "key", { *keys_for_command("FULL_RAW_MESSAGE"), - *keys_for_command("GO_BACK"), + *keys_for_command("EXIT_POPUP"), }, ) def test_keypress_show_msg_info( @@ -577,7 +692,7 @@ def test_keypress_exit_popup_invalid_key( assert not self.controller.exit_popup.called @pytest.mark.parametrize( - "key", {*keys_for_command("EDIT_HISTORY"), *keys_for_command("GO_BACK")} + "key", {*keys_for_command("EDIT_HISTORY"), *keys_for_command("EXIT_POPUP")} ) def test_keypress_show_msg_info( self, key: str, widget_size: Callable[[Widget], urwid_Size] @@ -754,7 +869,7 @@ def test_init(self, edit_mode_view: EditModeView) -> None: (2, "change_all"), ], ) - @pytest.mark.parametrize("key", keys_for_command("ENTER")) + @pytest.mark.parametrize("key", keys_for_command("ACTIVATE_BUTTON")) def test_select_edit_mode( self, edit_mode_view: EditModeView, @@ -800,7 +915,7 @@ def test_keypress_any_key( assert not self.controller.exit_popup.called @pytest.mark.parametrize( - "key", {*keys_for_command("GO_BACK"), *keys_for_command("MARKDOWN_HELP")} + "key", {*keys_for_command("EXIT_POPUP"), *keys_for_command("MARKDOWN_HELP")} ) def test_keypress_exit_popup( self, key: str, widget_size: Callable[[Widget], urwid_Size] @@ -831,7 +946,7 @@ def test_keypress_any_key( assert not self.controller.exit_popup.called @pytest.mark.parametrize( - "key", {*keys_for_command("GO_BACK"), *keys_for_command("HELP")} + "key", {*keys_for_command("EXIT_POPUP"), *keys_for_command("HELP")} ) def test_keypress_exit_popup( self, key: str, widget_size: Callable[[Widget], urwid_Size] @@ -1002,7 +1117,7 @@ def test_keypress_full_raw_message( ) @pytest.mark.parametrize( - "key", {*keys_for_command("GO_BACK"), *keys_for_command("MSG_INFO")} + "key", {*keys_for_command("EXIT_POPUP"), *keys_for_command("MSG_INFO")} ) def test_keypress_exit_popup( self, key: str, widget_size: Callable[[Widget], urwid_Size] @@ -1055,21 +1170,13 @@ def test_height_noreactions(self) -> None: { "emoji_name": "zulip", "emoji_code": "zulip", - "user": { - "email": "iago@zulip.com", - "full_name": "Iago", - "id": 5, - }, + "user_id": 5, "reaction_type": "zulip_extra_emoji", }, { "emoji_name": "zulip", "emoji_code": "zulip", - "user": { - "email": "AARON@zulip.com", - "full_name": "aaron", - "id": 1, - }, + "user_id": 1, "reaction_type": "zulip_extra_emoji", }, { @@ -1093,18 +1200,37 @@ def test_height_reactions( ) -> None: varied_message = message_fixture varied_message.update(to_vary_in_each_message) - self.msg_info_view = MsgInfoView( - self.controller, - varied_message, - "Message Information", - OrderedDict(), - OrderedDict(), - list(), - ) - # 12 = 7 labels + 2 blank lines + 1 'Reactions' (category) - # + 4 reactions (excluding 'Message Links'). - expected_height = 14 - assert self.msg_info_view.height == expected_height + + mock_all_users_by_id = { + 1: {"full_name": "aaron"}, + 5: {"full_name": "Iago"}, + } + + def mock_get_user_id(reaction: Dict[str, Any]) -> int: + if "user_id" in reaction: + return reaction["user_id"] + return reaction["user"]["id"] + + with patch.object( + self.controller.model, + "get_user_id_from_reaction", + side_effect=mock_get_user_id, + ), patch.object( + self.controller.model, "_all_users_by_id", mock_all_users_by_id + ): + self.msg_info_view = MsgInfoView( + self.controller, + varied_message, + "Message Information", + OrderedDict(), + OrderedDict(), + list(), + ) + + # 12 = 7 labels + 2 blank lines + 1 'Reactions' (category) + # + 4 reactions (excluding 'Message Links'). + expected_height = 14 + assert self.msg_info_view.height == expected_height @pytest.mark.parametrize( [ @@ -1206,16 +1332,16 @@ def test_keypress_stream_members( case( {"date_created": None, "is_announcement_only": True}, "74 [Organization default]", - None, + 0, 17, - id="ZFL=None_no_date_created__no_retention_days__admins_only", + id="ZFL=0_no_date_created__no_retention_days__admins_only", ), case( {"date_created": None, "is_announcement_only": False}, "74 [Organization default]", - None, + 0, 16, - id="ZFL=None_no_date_created__no_retention_days__anyone_can_type", + id="ZFL=0_no_date_created__no_retention_days__anyone_can_type", ), case( {"date_created": None, "stream_post_policy": 1}, @@ -1287,7 +1413,7 @@ def test_popup_height( general_stream: Dict[str, Any], to_vary_in_stream_data: Dict[str, Optional[int]], cached_message_retention_text: str, - server_feature_level: Optional[int], + server_feature_level: int, expected_height: int, ) -> None: model = self.controller.model @@ -1303,17 +1429,63 @@ def test_popup_height( # + 3(checkboxes) + [2-5](fields, depending upon server_feature_level) assert stream_info_view.height == expected_height + def test_stream_info_content__sections(self) -> None: + assert len(self.stream_info_view._stream_info_content) == 2 + + stream_details, stream_settings = self.stream_info_view._stream_info_content + assert stream_details[0] == "Stream Details" + assert stream_settings[0] == "Stream settings" + + @pytest.mark.parametrize( + "stream_email_present, expected_copy_text", + [ + (False, "< Stream email is unavailable >"), + (True, "Press 'c' to copy Stream email address"), + ], + ) + def test_stream_info_content__email_copy_text( + self, + general_stream: Dict[str, Any], + stream_email_present: bool, + expected_copy_text: str, + ) -> None: + if not stream_email_present: + del general_stream["email_address"] + self.controller.model.get_stream_email_address.return_value = None + + model = self.controller.model + stream_id = general_stream["stream_id"] + model.stream_dict = {stream_id: general_stream} + + # Custom, to enable variation of stream data before creation + stream_info_view = StreamInfoView(self.controller, stream_id) + + stream_details, _ = stream_info_view._stream_info_content + stream_details_data = stream_details[1] + + assert ("Stream email", expected_copy_text) in stream_details_data + + @pytest.mark.parametrize("normalized_email_address", ("user@example.com", None)) @pytest.mark.parametrize("key", keys_for_command("COPY_STREAM_EMAIL")) def test_keypress_copy_stream_email( - self, key: str, widget_size: Callable[[Widget], urwid_Size] + self, + key: str, + normalized_email_address: Optional[str], + widget_size: Callable[[Widget], urwid_Size], ) -> None: size = widget_size(self.stream_info_view) + # This patches inside the object, which is fragile but tests the logic + # Note that the assert uses the same variable + self.stream_info_view._stream_email = normalized_email_address self.stream_info_view.keypress(size, key) - self.controller.copy_to_clipboard.assert_called_once_with( - self.stream_info_view.stream_email, "Stream email" - ) + if normalized_email_address is not None: + self.controller.copy_to_clipboard.assert_called_once_with( + self.stream_info_view._stream_email, "Stream email" + ) + else: + self.controller.copy_to_clipboard.assert_not_called() @pytest.mark.parametrize( "rendered_description, expected_markup", @@ -1393,7 +1565,7 @@ def test_footlinks( assert footlinks_width == expected_footlinks_width @pytest.mark.parametrize( - "key", {*keys_for_command("GO_BACK"), *keys_for_command("STREAM_INFO")} + "key", {*keys_for_command("EXIT_POPUP"), *keys_for_command("STREAM_INFO")} ) def test_keypress_exit_popup( self, key: str, widget_size: Callable[[Widget], urwid_Size] @@ -1402,7 +1574,7 @@ def test_keypress_exit_popup( self.stream_info_view.keypress(size, key) assert self.controller.exit_popup.called - @pytest.mark.parametrize("key", (*keys_for_command("ENTER"), " ")) + @pytest.mark.parametrize("key", (*keys_for_command("ACTIVATE_BUTTON"), " ")) def test_checkbox_toggle_mute_stream( self, key: str, widget_size: Callable[[Widget], urwid_Size] ) -> None: @@ -1415,7 +1587,7 @@ def test_checkbox_toggle_mute_stream( toggle_mute_status.assert_called_once_with(stream_id) - @pytest.mark.parametrize("key", (*keys_for_command("ENTER"), " ")) + @pytest.mark.parametrize("key", (*keys_for_command("ACTIVATE_BUTTON"), " ")) def test_checkbox_toggle_pin_stream( self, key: str, widget_size: Callable[[Widget], urwid_Size] ) -> None: @@ -1428,7 +1600,7 @@ def test_checkbox_toggle_pin_stream( toggle_pin_status.assert_called_once_with(stream_id) - @pytest.mark.parametrize("key", (*keys_for_command("ENTER"), " ")) + @pytest.mark.parametrize("key", (*keys_for_command("ACTIVATE_BUTTON"), " ")) def test_checkbox_toggle_visual_notification( self, key: str, widget_size: Callable[[Widget], urwid_Size] ) -> None: @@ -1458,7 +1630,7 @@ def mock_external_classes(self, mocker: MockerFixture) -> None: self.stream_members_view = StreamMembersView(self.controller, stream_id) @pytest.mark.parametrize( - "key", {*keys_for_command("GO_BACK"), *keys_for_command("STREAM_MEMBERS")} + "key", {*keys_for_command("EXIT_POPUP"), *keys_for_command("STREAM_MEMBERS")} ) def test_keypress_exit_popup( self, key: str, widget_size: Callable[[Widget], urwid_Size] @@ -1573,7 +1745,7 @@ def test_keypress_search_emoji( assert self.emoji_picker_view.get_focus() == "header" @pytest.mark.parametrize( - "key", {*keys_for_command("GO_BACK"), *keys_for_command("ADD_REACTION")} + "key", {*keys_for_command("EXIT_POPUP"), *keys_for_command("ADD_REACTION")} ) def test_keypress_exit_called( self, key: str, widget_size: Callable[[Widget], urwid_Size] diff --git a/tests/widget/test_widget.py b/tests/widget/test_widget.py new file mode 100644 index 0000000000..ee2636e820 --- /dev/null +++ b/tests/widget/test_widget.py @@ -0,0 +1,345 @@ +from typing import Dict, List, Union + +import pytest +from pytest import param as case + +from zulipterminal.widget import Submessage, find_widget_type, process_todo_widget + + +@pytest.mark.parametrize( + "submessages, expected_widget_type", + [ + case( + [ + { + "id": 11897, + "message_id": 1954461, + "sender_id": 27294, + "msg_type": "widget", + "content": ( + '{"widget_type": "poll", "extra_data": ' + '{"question": "Sample Question?", "options": ["Yes", "No"]}}' + ), + }, + { + "id": 11898, + "message_id": 1954461, + "sender_id": 27294, + "msg_type": "widget", + "content": '{"type":"new_option","idx":1,"option":"Maybe"}', + }, + ], + "poll", + ), + case( + [ + { + "id": 11899, + "message_id": 1954463, + "sender_id": 27294, + "msg_type": "widget", + "content": ( + '{"widget_type": "todo", "extra_data": ' + '{"task_list_title": "Today\'s Tasks", "tasks": [{"task": ' + '"Write code", "desc": ""}, {"task": "Sleep", "desc": ""}]}}' + ), + }, + { + "id": 11900, + "message_id": 1954463, + "sender_id": 27294, + "msg_type": "widget", + "content": ( + '{"type":"new_task","key":2,"task":"Eat","desc":"",' + '"completed":false}' + ), + }, + ], + "todo", + ), + case([{}], "unknown"), + ], +) +def test_find_widget_type( + submessages: List[Submessage], expected_widget_type: str +) -> None: + widget_type = find_widget_type(submessages) + + assert widget_type == expected_widget_type + + +@pytest.mark.parametrize( + "submessages, expected_title, expected_tasks", + [ + case( + [ + { + "id": 11899, + "message_id": 1954463, + "sender_id": 27294, + "msg_type": "widget", + "content": ( + '{"widget_type": "todo", "extra_data": ' + '{"task_list_title": "Today\'s Tasks", "tasks": [{"task": ' + '"Write code", "desc": ""}, {"task": "Sleep", "desc": ""}]}}' + ), + }, + { + "id": 11900, + "message_id": 1954463, + "sender_id": 27294, + "msg_type": "widget", + "content": ( + '{"type":"new_task","key":2,"task":"Eat","desc":"",' + '"completed":false}' + ), + }, + ], + "Today's Tasks", + { + "0,canned": {"task": "Write code", "desc": "", "completed": False}, + "1,canned": {"task": "Sleep", "desc": "", "completed": False}, + "2,27294": {"task": "Eat", "desc": "", "completed": False}, + }, + id="title_and_unfinished_tasks", + ), + case( + [ + { + "id": 11912, + "message_id": 1954626, + "sender_id": 27294, + "msg_type": "widget", + "content": ( + '{"widget_type": "todo", "extra_data": ' + '{"task_list_title": "", "tasks": [{"task": "Hey", "desc": ""},' + ' {"task": "Hi", "desc": ""}]}}' + ), + } + ], + "Task list", + { + "0,canned": {"task": "Hey", "desc": "", "completed": False}, + "1,canned": {"task": "Hi", "desc": "", "completed": False}, + }, + id="no_title_and_unfinished_tasks", + ), + case( + [ + { + "id": 11919, + "message_id": 1954843, + "sender_id": 27294, + "msg_type": "widget", + "content": ( + '{"widget_type": "todo", "extra_data": ' + '{"task_list_title": "", "tasks": []}}' + ), + } + ], + "Task list", + {}, + id="no_title_or_tasks", + ), + case( + [ + { + "id": 11932, + "message_id": 1954847, + "sender_id": 27294, + "msg_type": "widget", + "content": ( + '{"widget_type": "todo", "extra_data": ' + '{"task_list_title": "", "tasks": []}}' + ), + }, + { + "id": 11933, + "message_id": 1954847, + "sender_id": 27294, + "msg_type": "widget", + "content": ( + '{"type":"new_task","key":2,"task":"Write code",' + '"desc":"Make the todo ZT PR!","completed":false}' + ), + }, + { + "id": 11934, + "message_id": 1954847, + "sender_id": 27294, + "msg_type": "widget", + "content": ( + '{"type":"new_task","key":4,"task":"Sleep",' + '"desc":"at least 8 hours a day","completed":false}' + ), + }, + { + "id": 11935, + "message_id": 1954847, + "sender_id": 27294, + "msg_type": "widget", + "content": ( + '{"type":"new_task","key":6,"task":"Eat",' + '"desc":"3 meals a day","completed":false}' + ), + }, + { + "id": 11936, + "message_id": 1954847, + "sender_id": 27294, + "msg_type": "widget", + "content": ( + '{"type":"new_task","key":8,"task":"Exercise",' + '"desc":"an hour a day","completed":false}' + ), + }, + { + "id": 11937, + "message_id": 1954847, + "sender_id": 27294, + "msg_type": "widget", + "content": '{"type":"strike","key":"2,27294"}', + }, + { + "id": 11938, + "message_id": 1954847, + "sender_id": 27294, + "msg_type": "widget", + "content": '{"type":"strike","key":"2,27294"}', + }, + { + "id": 11939, + "message_id": 1954847, + "sender_id": 27294, + "msg_type": "widget", + "content": '{"type":"strike","key":"4,27294"}', + }, + { + "id": 11940, + "message_id": 1954847, + "sender_id": 27294, + "msg_type": "widget", + "content": '{"type":"strike","key":"6,27294"}', + }, + { + "id": 11941, + "message_id": 1954847, + "sender_id": 27294, + "msg_type": "widget", + "content": '{"type":"strike","key":"8,27294"}', + }, + { + "id": 11942, + "message_id": 1954847, + "sender_id": 27294, + "msg_type": "widget", + "content": '{"type":"strike","key":"2,27294"}', + }, + { + "id": 11943, + "message_id": 1954847, + "sender_id": 27294, + "msg_type": "widget", + "content": '{"type":"strike","key":"4,27294"}', + }, + { + "id": 11944, + "message_id": 1954847, + "sender_id": 27294, + "msg_type": "widget", + "content": '{"type":"strike","key":"6,27294"}', + }, + { + "id": 11945, + "message_id": 1954847, + "sender_id": 27294, + "msg_type": "widget", + "content": '{"type":"strike","key":"4,27294"}', + }, + { + "id": 11946, + "message_id": 1954847, + "sender_id": 27294, + "msg_type": "widget", + "content": '{"type":"strike","key":"8,27294"}', + }, + ], + "Task list", + { + "2,27294": { + "task": "Write code", + "desc": "Make the todo ZT PR!", + "completed": True, + }, + "4,27294": { + "task": "Sleep", + "desc": "at least 8 hours a day", + "completed": True, + }, + "6,27294": {"task": "Eat", "desc": "3 meals a day", "completed": False}, + "8,27294": { + "task": "Exercise", + "desc": "an hour a day", + "completed": False, + }, + }, + id="title_and_description_and_finished_tasks", + ), + case( + [ + { + "id": 12143, + "message_id": 1958318, + "sender_id": 27294, + "msg_type": "widget", + "content": ( + '{"widget_type": "todo", "extra_data": {' + '"task_list_title": "Today\'s Work", "tasks": [{"task": ' + '"Update todo titles on ZT", "desc": ""}, ' + '{"task": "Push todo update", "desc": ""}]}}' + ), + }, + { + "id": 12144, + "message_id": 1958318, + "sender_id": 27294, + "msg_type": "widget", + "content": ( + '{"type":"new_task_list_title",' + '"title":"Today\'s Work [Updated]"}' + ), + }, + { + "id": 12145, + "message_id": 1958318, + "sender_id": 27294, + "msg_type": "widget", + "content": '{"type":"strike","key":"0,canned"}', + }, + ], + "Today's Work [Updated]", + { + "0,canned": { + "task": "Update todo titles on ZT", + "desc": "", + "completed": True, + }, + "1,canned": { + "task": "Push todo update", + "desc": "", + "completed": False, + }, + }, + id="updated_title_and_finished_tasks", + ), + ], +) +def test_process_todo_widget( + submessages: List[Submessage], + expected_title: str, + expected_tasks: Dict[str, Dict[str, Union[str, bool]]], +) -> None: + title, tasks = process_todo_widget(submessages) + + assert title == expected_title + assert tasks == expected_tasks diff --git a/tools/convert-unicode-emoji-data b/tools/convert-unicode-emoji-data index 015e39e326..2e7b307520 100755 --- a/tools/convert-unicode-emoji-data +++ b/tools/convert-unicode-emoji-data @@ -3,12 +3,14 @@ # Convert emoji file downloaded using tools/fetch-unicode-emoji-data # into what is required by ZT. -from collections import OrderedDict from pathlib import Path, PurePath try: - from zulipterminal.unicode_emoji_dict import EMOJI_NAME_MAPS + # Ignored for type-checking, as it is a temporary file, deleted at the end of file + from zulipterminal.unicode_emoji_dict import ( # type: ignore [import-not-found,import-untyped,unused-ignore] + EMOJI_NAME_MAPS, + ) except ModuleNotFoundError: print( "ERROR: Could not find downloaded unicode emoji\n" @@ -32,7 +34,7 @@ for emoji_code, emoji_data in emoji_dict.items(): "aliases": emoji_data["aliases"], } -ordered_emojis = OrderedDict(sorted(emoji_map.items())) +ordered_emojis = dict(sorted(emoji_map.items())) with OUTPUT_FILE.open("w") as f: f.write( @@ -44,23 +46,20 @@ with OUTPUT_FILE.open("w") as f: "# Ignore long lines", "# ruff: noqa: E501", "", - "from collections import OrderedDict\n\n", "# fmt: off", f"# Generated automatically by tools/{SCRIPT_NAME}", "# Do not modify.\n", - "EMOJI_DATA = OrderedDict(", - " [\n", + "EMOJI_DATA = {\n", ] ) ) for emoji_name, emoji in ordered_emojis.items(): # {'smile': {'code': '263a', 'aliases': []}} - f.write(f' ("{emoji_name}", {emoji}),\n') + f.write(f' "{emoji_name}": {emoji},\n') f.write( "\n".join( [ - " ]", - ")", + "}", "# fmt: on", "", ] diff --git a/tools/lint-all b/tools/lint-all index 1b148fc6e1..b9a38462bb 100755 --- a/tools/lint-all +++ b/tools/lint-all @@ -15,10 +15,10 @@ python_sources = [ python_sources += lintable_tools_files tools = { - "Import order (isort)": "./tools/run-isort-check", - "Type consistency (mypy)": "./tools/run-mypy", "PEP8 & more (ruff)": ["ruff"] + python_sources, "Formatting (black)": ["black", "--check"] + python_sources, + "Import order (isort)": "./tools/run-isort-check", + "Type consistency (mypy)": "./tools/run-mypy", "Common spelling mistakes": "./tools/run-spellcheck", "Hotkey linting & docs sync check": ["./tools/lint-hotkeys"], "Docstring linting & docs sync check (lint-docstring)": "./tools/lint-docstring", diff --git a/tools/lint-hotkeys b/tools/lint-hotkeys index 68e9f8cdc2..c687b87f80 100755 --- a/tools/lint-hotkeys +++ b/tools/lint-hotkeys @@ -6,7 +6,11 @@ from collections import defaultdict from pathlib import Path, PurePath from typing import Dict, List, Tuple -from zulipterminal.config.keys import HELP_CATEGORIES, KEY_BINDINGS +from zulipterminal.config.keys import ( + HELP_CATEGORIES, + KEY_BINDINGS, + display_keys_for_command, +) KEYS_FILE = ( @@ -19,7 +23,7 @@ SCRIPT_NAME = PurePath(__file__).name HELP_TEXT_STYLE = re.compile(r"^[a-zA-Z /()',&@#:_-]*$") # Exclude keys from duplicate keys checking -KEYS_TO_EXCLUDE = ["q", "e", "m", "r"] +KEYS_TO_EXCLUDE = ["q", "e", "m", "r", "Esc", "c"] def main(fix: bool) -> None: @@ -69,7 +73,9 @@ def lint_hotkeys_file() -> None: else: print("No hotkeys linting errors") if not output_file_matches_string(hotkeys_file_string): - print(f"Run './tools/{SCRIPT_NAME}' to update {OUTPUT_FILE_NAME} file") + print( + f"Run './tools/{SCRIPT_NAME} --fix' to update {OUTPUT_FILE_NAME} file" + ) error_flag = 1 sys.exit(error_flag) @@ -129,8 +135,10 @@ def read_help_categories() -> Dict[str, List[Tuple[str, List[str]]]]: Get all help categories from KEYS_FILE """ categories = defaultdict(list) - for item in KEY_BINDINGS.values(): - categories[item["key_category"]].append((item["help_text"], item["keys"])) + for cmd, item in KEY_BINDINGS.items(): + categories[item["key_category"]].append( + (item["help_text"], display_keys_for_command(cmd)) + ) return categories diff --git a/tools/lister.py b/tools/lister.py index 00f1eb4795..a3f81aa6fe 100755 --- a/tools/lister.py +++ b/tools/lister.py @@ -6,7 +6,7 @@ import subprocess import sys from collections import defaultdict -from typing import Dict, List +from typing import Dict, List, Sequence, Union def get_ftype(fpath: str, use_shebang: bool) -> str: @@ -33,14 +33,14 @@ def get_ftype(fpath: str, use_shebang: bool) -> str: def list_files( - targets=[], - ftypes=[], - use_shebang=True, - modified_only=False, - exclude=[], - group_by_ftype=False, - extless_only=False, -): + targets: Sequence[str] = [], + ftypes: Sequence[str] = [], + use_shebang: bool = True, + modified_only: bool = False, + exclude: Sequence[str] = [], + group_by_ftype: bool = False, + extless_only: bool = False, +) -> Union[Dict[str, List[str]], List[str]]: """ List files tracked by git. Returns a list of files which are either in targets or in directories in @@ -74,7 +74,7 @@ def list_files( os.path.abspath(os.path.join(repository_root, fpath)) for fpath in exclude ] - cmdline = ["git", "ls-files"] + targets + cmdline = ["git", "ls-files", *targets] if modified_only: cmdline.append("-m") diff --git a/tools/run-mypy b/tools/run-mypy index b30ef68f81..560a301a55 100755 --- a/tools/run-mypy +++ b/tools/run-mypy @@ -15,10 +15,14 @@ EXCLUDE_FILES = [ "tools/fetch-pull-request", "tools/fetch-rebase-pull-request", "tools/check-branch", - "tools/lister.py", # Came from zulip/zulip, now in zulint? ] -python_project_folders = ["zulipterminal", "tests", "tools"] +project_mypy_args: Dict[str, List[str]] = { + "zulipterminal": [], + "tests": ["--implicit-reexport"], + "tools": [], +} +python_project_folders = list(project_mypy_args) TOOLS_DIR = os.path.dirname(os.path.abspath(__file__)) os.chdir(os.path.dirname(TOOLS_DIR)) @@ -54,14 +58,6 @@ parser.add_argument( find out which files fail mypy check.""", ) -parser.add_argument( - "--no-disallow-untyped-defs", - dest="disallow_untyped_defs", - action="store_false", - default=True, - help="""Don't throw errors when functions are not annotated""", -) - parser.add_argument( "--scripts-only", dest="scripts_only", @@ -70,37 +66,6 @@ parser.add_argument( help="""Only type check extensionless python scripts""", ) -parser.add_argument( - "--no-strict-optional", - dest="strict_optional", - action="store_false", - default=True, - help="""Don't use the --strict-optional flag with mypy""", -) - -parser.add_argument( - "--warn-unused-ignores", - dest="warn_unused_ignores", - action="store_true", - default=False, - help="""Use the --warn-unused-ignores flag with mypy""", -) - -parser.add_argument( - "--no-ignore-missing-imports", - dest="ignore_missing_imports", - action="store_false", - default=True, - help="""Don't use the --ignore-missing-imports flag with mypy""", -) - -parser.add_argument( - "--quick", - action="store_true", - default=False, - help="""Use the --quick flag with mypy""", -) - args = parser.parse_args() files_dict = cast( @@ -135,30 +100,15 @@ for file_path in python_files: mypy_command = "mypy" -extra_args = [ - "--check-untyped-defs", - "--follow-imports=silent", - "--scripts-are-modules", - "--disallow-any-generics", - "-i", -] -if args.disallow_untyped_defs: - extra_args.append("--disallow-untyped-defs") -if args.warn_unused_ignores: - extra_args.append("--warn-unused-ignores") -if args.strict_optional: - extra_args.append("--strict-optional") -if args.ignore_missing_imports: - extra_args.append("--ignore-missing-imports") -if args.quick: - extra_args.append("--quick") +extra_args: List[str] = [] # run mypy status = 0 for repo, python_files in repo_python_files.items(): print(f"Running mypy for `{repo}`.", flush=True) + repo_args = project_mypy_args[repo] if python_files: - result = subprocess.call([mypy_command] + extra_args + python_files) + result = subprocess.call([mypy_command] + extra_args + repo_args + python_files) if result != 0: status = result else: diff --git a/tools/run-spellcheck b/tools/run-spellcheck index fac125626d..2b42de31d9 100755 --- a/tools/run-spellcheck +++ b/tools/run-spellcheck @@ -32,7 +32,6 @@ EXCLUDE_FILES = [ "tests/ui_tools/test_boxes.py", # Word prefixes as part of autocomplete test "tests/config/test_themes.py", # Wrong words as part of theme syntax test "tests/model/test_model.py", # 2nd not recognized crate-ci/typos#466 - "docs/FAQ.md", # iterm2 is proper noun [term, item, interm] "tools/run-spellcheck", # Exclude ourself due to notes ] diff --git a/zulipterminal/api_types.py b/zulipterminal/api_types.py index a772c576b4..b6afa2bb1d 100644 --- a/zulipterminal/api_types.py +++ b/zulipterminal/api_types.py @@ -6,7 +6,7 @@ from typing import Any, Dict, List, Optional, Union -from typing_extensions import Literal, NotRequired, TypedDict +from typing_extensions import Final, Literal, NotRequired, TypedDict, final # These are documented in the zulip package (python-zulip-api repo) from zulip import EditPropagateMode # one/all/later @@ -15,30 +15,114 @@ from zulip import ModifiableMessageFlag # directly modifiable read/starred/collapsed +# This marks imported names that are intended for importing elsewhere +__all__ = [ + "EditPropagateMode", + "EmojiType", +] + + RESOLVED_TOPIC_PREFIX = "✔ " -# Refer to https://zulip.com/api/set-typing-status for the protocol -# on typing notifications sent by clients. -TYPING_STARTED_WAIT_PERIOD = 10 -TYPING_STOPPED_WAIT_PERIOD = 5 +############################################################################### +# These values are in the register response from ZFL 53 +# Before this feature level, they had the listed default (fixed) values +# (strictly, the stream value was available, under a different name) + +MAX_STREAM_NAME_LENGTH: Final = 60 +MAX_TOPIC_NAME_LENGTH: Final = 60 +MAX_MESSAGE_LENGTH: Final = 10000 + + +############################################################################### +# These values are in the register response from ZFL 164 +# Before this feature level, they had the listed default (fixed) values + +PRESENCE_OFFLINE_THRESHOLD_SECS: Final = 140 +PRESENCE_PING_INTERVAL_SECS: Final = 60 + + +############################################################################### +# Core message types (used in Composition and Message below) + +DirectMessageString = Literal["private"] +StreamMessageString = Literal["stream"] + +MessageType = Union[DirectMessageString, StreamMessageString] + + +############################################################################### +# Parameters to pass in request to: +# https://zulip.com/api/set-typing-status +# Refer to the top of that page for the expected protocol clients should observe +# +# NOTE: `to` field could be email until ZFL 11/3.0; ids were possible from 2.0+ + +# In ZFL 204, these values were made server-configurable +# Before this feature level, these values were fixed as follows: +# Timing parameters for when notifications should occur (in milliseconds) +TYPING_STARTED_WAIT_PERIOD: Final = 10000 +TYPING_STOPPED_WAIT_PERIOD: Final = 5000 +TYPING_STARTED_EXPIRY_PERIOD: Final = 15000 # TODO: Needs implementation in ZT + +TypingStatusChange = Literal["start", "stop"] + + +class DirectTypingNotification(TypedDict): + # The type field was added in ZFL 58, Zulip 4.0, so don't require it yet + ## type: DirectMessageString + op: TypingStatusChange + to: List[int] + + +# NOTE: Not yet implemented in ZT +# New in ZFL 58, Zulip 4.0 +class StreamTypingNotification(TypedDict): + type: StreamMessageString + op: TypingStatusChange + to: List[int] # NOTE: Length 1, stream id + topic: str + + +############################################################################### +# Parameter to pass in request to: +# https://zulip.com/api/send-message class PrivateComposition(TypedDict): - type: Literal["private"] + type: DirectMessageString content: str to: List[int] # User ids + read_by_sender: bool # New in ZFL 236, Zulip 8.0 class StreamComposition(TypedDict): - type: Literal["stream"] + type: StreamMessageString content: str to: str # stream name # TODO: Migrate to using int (stream id) subject: str # TODO: Migrate to using topic + read_by_sender: bool # New in ZFL 236, Zulip 8.0 -# https://zulip.com/api/send-message Composition = Union[PrivateComposition, StreamComposition] +############################################################################### +# Parameters to pass in request to: +# https://zulip.com/api/update-message-flags + +MessageFlagStatusChange = Literal["add", "remove"] + + +class MessagesFlagChange(TypedDict): + messages: List[int] + op: MessageFlagStatusChange + flag: ModifiableMessageFlag + + +############################################################################### +# Parameter to pass in request to: +# https://zulip.com/api/update-message + class PrivateMessageUpdateRequest(TypedDict): message_id: int @@ -64,9 +148,38 @@ class StreamMessageUpdateRequest(TypedDict): # stream_id: int -# https://zulip.com/api/update-message MessageUpdateRequest = Union[PrivateMessageUpdateRequest, StreamMessageUpdateRequest] +############################################################################### +# Parameter to pass in request to: +# https://zulip.com/api/update-subscription-settings + +PersonalSubscriptionSetting = Literal[ + "in_home_view", "is_muted", "pin_to_top", "desktop_notifications" +] +# Currently unsupported in ZT: +# - color # TODO: Add support (value is str not bool) +# - audible_notifications +# - push_notifications +# - email_notifications +# - wildcard_mentions_notify # TODO: Add support + + +class SubscriptionSettingChange(TypedDict): + stream_id: int + property: PersonalSubscriptionSetting + value: bool + + +############################################################################### +# In "messages" response from: +# https://zulip.com/api/get-messages +# In "message" response from: +# https://zulip.com/api/get-events#message +# https://zulip.com/api/get-message (unused) + +## TODO: Improve this typing to split private and stream message data + class Message(TypedDict, total=False): id: int @@ -88,7 +201,7 @@ class Message(TypedDict, total=False): sender_email: str sender_realm_str: str display_recipient: Any - type: str + type: MessageType stream_id: int # Only for stream msgs. avatar_url: str content_type: str @@ -102,7 +215,14 @@ class Message(TypedDict, total=False): # sender_short_name: str -# Elements and types taken from https://zulip.com/api/get-events +############################################################################### +# In "subscriptions" response from: +# https://zulip.com/api/register-queue +# Also directly from: +# https://zulip.com/api/get-events#subscription-add +# https://zulip.com/api/get-subscriptions (unused) + + class Subscription(TypedDict): stream_id: int name: str @@ -117,7 +237,7 @@ class Subscription(TypedDict): push_notifications: Optional[bool] audible_notifications: Optional[bool] pin_to_top: bool - email_address: str + email_address: NotRequired[str] # Replaced by new endpoint in Zulip 7.5 (ZFL 226) is_muted: bool @@ -136,6 +256,41 @@ class Subscription(TypedDict): # in_home_view: bool # Replaced by is_muted in Zulip 2.1; still present in updates +############################################################################### +# In "custom_profile_fields" response from: +# https://zulip.com/api/register-queue +# Also directly from: +# https://zulip.com/api/get-events#custom_profile_fields +# NOTE: This data structure is currently used in conftest.py to improve +# typing of fixtures, and can be used when initial_data is refactored to have +# better typing. + + +class CustomProfileField(TypedDict): + id: int + name: str + type: Literal[1, 2, 3, 4, 5, 6, 7, 8] # Field types range from 1 to 8. + hint: str + field_data: str + order: int + + +############################################################################### +# In "realm_user" response from: +# https://zulip.com/api/register-queue +# Also directly from: +# https://zulip.com/api/get-events#realm_user-add +# https://zulip.com/api/get-users (unused) +# https://zulip.com/api/get-own-user (unused) +# https://zulip.com/api/get-user (unused) +# NOTE: Responses between versions & endpoints vary + + +class CustomFieldValue(TypedDict): + value: str + rendered_value: NotRequired[str] + + class RealmUser(TypedDict): user_id: int full_name: str @@ -162,8 +317,9 @@ class RealmUser(TypedDict): is_admin: bool is_guest: bool # NOTE: added /users/me ZFL 10; other changes before that + profile_data: Dict[str, CustomFieldValue] + # To support in future: - # profile_data: Dict # NOTE: Only if requested # is_active: bool # NOTE: Dependent upon realm_users vs realm_non_active_users # delivery_email: str # NOTE: Only available if admin, and email visibility limited @@ -173,105 +329,249 @@ class RealmUser(TypedDict): # max_message_id: int # NOTE: DEPRECATED & only for /users/me +############################################################################### +# Events possible in "events" from: +# https://zulip.com/api/get-events +# (also helper data structures not used elsewhere) + + +# ----------------------------------------------------------------------------- +# See https://zulip.com/api/get-events#message class MessageEvent(TypedDict): type: Literal["message"] message: Message flags: List[MessageFlag] -class UpdateMessageEvent(TypedDict): +# ----------------------------------------------------------------------------- +# See https://zulip.com/api/get-events#update_message +# NOTE: A single "update_message" event can be both derived event classes + + +class BaseUpdateMessageEvent(TypedDict): type: Literal["update_message"] + + # Present in both cases: + # - specific message content being updated + # - move one message (change_one) or this message and those later (change_later) message_id: int - # FIXME: These groups of types are not always present - # A: Content needs re-rendering + # Present in both cases; message_id may change read/mention/alert status + # flags: List[MessageFlag] + + # Omitted before Zulip 5.0 / ZFL 114 for rendering-only updates + # Subsequently always present (and None for rendering_only==True) + # user_id: NotRequired[Optional[int]] # sender + # edit_timestamp: NotRequired[int] + + # When True, does not relate to user-generated edit or message history + # Prior to Zulip 5.0 / ZFL 114, detect via presence/absence of user_id + # rendering_only: NotRequired[bool] # New in Zulip 5.0 / ZFL 114 + + +class UpdateMessageContentEvent(BaseUpdateMessageEvent): + # stream_name: str # Not recommended; prefer stream_id + # stream_id: NotRequired[int] # Only if a stream message + + # orig_rendered_content: str rendered_content: str - # B: Subject of these message ids needs updating? + + # Not used since we parse the rendered_content only + # orig_content: str + # content: str + + is_me_message: bool + + +class UpdateMessagesLocationEvent(BaseUpdateMessageEvent): + # All previously sent to stream_id with topic orig_subject message_ids: List[int] + + # Old location of messages + # stream_name: str # Not recommended; prefer stream_id + stream_id: int orig_subject: str - subject: str + propagate_mode: EditPropagateMode - stream_id: int + + # Only present if messages are moved to a different topic + # eg. if subject unchanged, but stream does change, these will be absent + subject: NotRequired[str] + # subject_links: NotRequired[List[Any]] + # topic_links: NotRequired[List[Any]] + + # Only present if messages are moved to a different stream + # new_stream_id: NotRequired[int] +# ----------------------------------------------------------------------------- +# See https://zulip.com/api/get-events#reaction-add and -remove class ReactionEvent(TypedDict): type: Literal["reaction"] op: str - user: Dict[str, Any] # 'email', 'user_id', 'full_name' + user_id: NotRequired[int] # Added in Zulip v3.0, ZFL 2 replacing 'user' + user: NotRequired[Dict[str, Any]] # 'email', 'user_id', 'full_name' reaction_type: EmojiType emoji_code: str emoji_name: str message_id: int -class RealmUserEventPerson(TypedDict): - user_id: int +# ----------------------------------------------------------------------------- +# See https://zulip.com/api/get-events#realm_user-add and -remove + + +class UpdateCustomFieldValue(TypedDict): + id: int + value: Optional[str] + rendered_value: NotRequired[str] + +@final +class RealmUserUpdateName(TypedDict): + user_id: int full_name: str + +@final +class RealmUserUpdateAvatar(TypedDict): + user_id: int avatar_url: str avatar_source: str avatar_url_medium: str avatar_version: int + +@final +class RealmUserUpdateTimeZone(TypedDict): + user_id: int # NOTE: This field will be removed in future as it is redundant with the user_id # email: str timezone: str + +@final +class RealmUserUpdateBotOwner(TypedDict): + user_id: int bot_owner_id: int + +@final +class RealmUserUpdateRole(TypedDict): + user_id: int role: int + +@final +class RealmUserUpdateBillingRole(TypedDict): + user_id: int is_billing_admin: bool # New in ZFL 73 (Zulip 5.0) + +@final +class RealmUserUpdateDeliveryEmail(TypedDict): + user_id: int delivery_email: str # NOTE: Only sent to admins - # custom_profile_field: Dict # TODO: Requires checking before implementation +@final +class RealmUserUpdateCustomProfileField(TypedDict): + user_id: int + custom_profile_field: UpdateCustomFieldValue + + +@final +class RealmUserUpdateEmail(TypedDict): + user_id: int new_email: str +RealmUserEventPerson = Union[ + RealmUserUpdateName, + RealmUserUpdateAvatar, + RealmUserUpdateTimeZone, + RealmUserUpdateBotOwner, + RealmUserUpdateRole, + RealmUserUpdateBillingRole, + RealmUserUpdateDeliveryEmail, + RealmUserUpdateCustomProfileField, + RealmUserUpdateEmail, +] + + class RealmUserEvent(TypedDict): type: Literal["realm_user"] op: Literal["update"] person: RealmUserEventPerson -class SubscriptionEvent(TypedDict): +class SubmessageEvent(TypedDict): + type: Literal["submessage"] + msg_type: str + message_id: int + submessage_id: int + sender_id: int + content: str + + +# ----------------------------------------------------------------------------- +# See https://zulip.com/api/get-events#subscription-update +# (also -peer_add and -peer_remove; FIXME: -add & -remove are not yet supported) + + +# Update of personal properties +class SubscriptionUpdateEvent(SubscriptionSettingChange): type: Literal["subscription"] - op: str - property: str + op: Literal["update"] - user_id: int # Present when a streams subscribers are updated. - user_ids: List[int] # NOTE: replaces 'user_id' in ZFL 35 - stream_id: int - stream_ids: List[int] # NOTE: replaces 'stream_id' in ZFL 35 for peer* +# User(s) have been (un)subscribed from stream(s) +class SubscriptionPeerAddRemoveEvent(TypedDict): + type: Literal["subscription"] + op: Literal["peer_add", "peer_remove"] - value: bool - message_ids: List[int] # Present when subject of msg(s) is updated + stream_id: NotRequired[int] + stream_ids: NotRequired[List[int]] # NOTE: replaces 'stream_id' in ZFL 35 + + user_id: NotRequired[int] + user_ids: NotRequired[List[int]] # NOTE: replaces 'user_id' in ZFL 35 + + +# ----------------------------------------------------------------------------- +# See https://zulip.com/api/get-events#typing-start and -stop +class _TypingEventUser(TypedDict): + user_id: int + email: str class TypingEvent(TypedDict): type: Literal["typing"] - sender: Dict[str, Any] # 'email', ... - op: str + op: TypingStatusChange + sender: _TypingEventUser + + # Unused as yet + # Pre Zulip 4.0, always present; now only present if message_type == "private" + # recipients: List[_TypingEventUser] + # NOTE: These fields are all new in Zulip 4.0 / ZFL 58, if client capability sent + # message_type: NotRequired[MessageType] + # stream_id: NotRequired[int] # Only present if message_type == "stream" + # topic: NotRequired[str] # Only present if message_type == "stream" + + +# ----------------------------------------------------------------------------- +# See https://zulip.com/api/get-events#update_message_flags-add and -remove class UpdateMessageFlagsEvent(TypedDict): type: Literal["update_message_flags"] messages: List[int] - operation: str # NOTE: deprecated in Zulip 4.0 / ZFL 32 -> 'op' - op: str + operation: MessageFlagStatusChange # NOTE: deprecated in Zulip 4.0 / ZFL 32 -> 'op' + op: MessageFlagStatusChange flag: ModifiableMessageFlag all: bool -class UpdateDisplaySettings(TypedDict): - type: Literal["update_display_settings"] - setting_name: str - setting: bool - - +# ----------------------------------------------------------------------------- +# See https://zulip.com/api/get-events#realm_emoji-update class RealmEmojiData(TypedDict): id: str name: str @@ -286,6 +586,8 @@ class UpdateRealmEmojiEvent(TypedDict): realm_emoji: Dict[str, RealmEmojiData] +# ----------------------------------------------------------------------------- +# See https://zulip.com/api/get-events#user_settings-update # This is specifically only those supported by ZT SupportedUserSettings = Literal["send_private_typing_notifications"] @@ -297,6 +599,8 @@ class UpdateUserSettingsEvent(TypedDict): value: Any +# ----------------------------------------------------------------------------- +# See https://zulip.com/api/get-events#update_global_notifications # This is specifically only those supported by ZT SupportedGlobalNotificationSettings = Literal["pm_content_in_desktop_notifications"] @@ -307,16 +611,87 @@ class UpdateGlobalNotificationsEvent(TypedDict): setting: Any +# ----------------------------------------------------------------------------- +# See https://zulip.com/api/get-events#update_display_settings +# This is specifically only those supported by ZT +SupportedDisplaySettings = Literal["twenty_four_hour_time"] + + +class UpdateDisplaySettingsEvent(TypedDict): + type: Literal["update_display_settings"] + setting_name: SupportedDisplaySettings + setting: bool + + +# ----------------------------------------------------------------------------- Event = Union[ MessageEvent, - UpdateMessageEvent, + UpdateMessageContentEvent, + UpdateMessagesLocationEvent, ReactionEvent, - SubscriptionEvent, + SubmessageEvent, + SubscriptionUpdateEvent, + SubscriptionPeerAddRemoveEvent, TypingEvent, UpdateMessageFlagsEvent, - UpdateDisplaySettings, + UpdateDisplaySettingsEvent, UpdateRealmEmojiEvent, UpdateUserSettingsEvent, UpdateGlobalNotificationsEvent, RealmUserEvent, ] + +############################################################################### +# In response from: +# https://zulip.com/api/get-server-settings + +AuthenticationMethod = Literal[ + "password", + "dev", + "email", + "ldap", + "remoteuser", + "github", + "azuread", + "gitlab", # New in Zulip 3.0, ZFL 1 + "apple", + "google", + "saml", + "openid_connect", +] + + +class ExternalAuthenticationMethod(TypedDict): + name: str + display_name: str + display_icon: Optional[str] + login_url: str + signup_url: str + + +# As of ZFL 121 +class ServerSettings(TypedDict): + # authentication_methods is deprecated in favor of external_authentication_methods + authentication_methods: Dict[AuthenticationMethod, bool] + # Added in Zulip 2.1.0 + external_authentication_methods: List[ExternalAuthenticationMethod] + + zulip_feature_level: NotRequired[int] # New in Zulip 3.0, ZFL 1 + zulip_version: str + zulip_merge_base: NotRequired[str] # New in Zulip 5.0, ZFL 88 + + push_notifications_enabled: bool + is_incompatible: bool + email_auth_enabled: bool + require_email_format_usernames: bool + + # This appears to be present for all Zulip servers, even for no organization, + # which makes it useful to determine a 'preferred' URL for the server/organization + realm_uri: str + + # These may only be present if it's an organization, not just a Zulip server + # Re realm_name discussion, See #api document > /server_settings: `realm_name`, etc. + realm_name: NotRequired[str] # Absence indicates root Zulip server but no realm + realm_icon: str + realm_description: str + realm_web_public_access_enabled: NotRequired[bool] # New in Zulip 5.0, ZFL 116 diff --git a/zulipterminal/cli/run.py b/zulipterminal/cli/run.py index 225f37d0fe..bb66b34fa4 100755 --- a/zulipterminal/cli/run.py +++ b/zulipterminal/cli/run.py @@ -4,6 +4,7 @@ import argparse import configparser +import contextlib import logging import os import stat @@ -11,13 +12,14 @@ import traceback from enum import Enum from os import path, remove -from typing import Any, Dict, List, NamedTuple, Optional, Tuple +from typing import Dict, List, NamedTuple, Optional, Tuple import requests from urwid import display_common, set_encoding +from zulipterminal.api_types import ServerSettings from zulipterminal.config.themes import ( - InvalidThemeColorCode, + ThemeError, aliased_themes, all_themes, complete_and_incomplete_themes, @@ -25,13 +27,14 @@ ) from zulipterminal.core import Controller from zulipterminal.model import ServerConnectionFailure -from zulipterminal.platform_code import detected_platform +from zulipterminal.platform_code import detected_platform, detected_python_in_full from zulipterminal.version import ZT_VERSION class ConfigSource(Enum): DEFAULT = "from default config" ZULIPRC = "in zuliprc file" + ENV = "from environment" COMMANDLINE = "on command line" @@ -60,6 +63,8 @@ class SettingData(NamedTuple): VALID_BOOLEAN_SETTINGS: Dict[str, Tuple[str, str]] = { "autohide": ("autohide", "no_autohide"), "notify": ("enabled", "disabled"), + "exit_confirmation": ("enabled", "disabled"), + "transparency": ("enabled", "disabled"), } COLOR_DEPTH_ARGS_TO_DEPTHS: Dict[str, int] = { @@ -77,10 +82,17 @@ class SettingData(NamedTuple): "footlinks": "enabled", "color-depth": "256", "maximum-footlinks": "3", + "exit_confirmation": "enabled", + "transparency": "disabled", + "editor": "", } assert DEFAULT_SETTINGS["autohide"] in VALID_BOOLEAN_SETTINGS["autohide"] assert DEFAULT_SETTINGS["notify"] in VALID_BOOLEAN_SETTINGS["notify"] assert DEFAULT_SETTINGS["color-depth"] in COLOR_DEPTH_ARGS_TO_DEPTHS +assert ( + DEFAULT_SETTINGS["exit_confirmation"] in VALID_BOOLEAN_SETTINGS["exit_confirmation"] +) +assert DEFAULT_SETTINGS["transparency"] in VALID_BOOLEAN_SETTINGS["transparency"] def in_color(color: str, text: str) -> str: @@ -149,6 +161,22 @@ def parse_args(argv: List[str]) -> argparse.Namespace: help="do not mark messages as read in the session", ) + transparency_group = parser.add_mutually_exclusive_group() + transparency_group.add_argument( + "--transparency", + dest="transparency", + action="store_const", + const="enabled", + help="enable transparent background (if supported by theme and terminal)", + ) + transparency_group.add_argument( + "--no-transparency", + dest="transparency", + action="store_const", + const="disabled", + help="disable transparent background", + ) + notify_group = parser.add_mutually_exclusive_group() notify_group.add_argument( "--notify", @@ -206,31 +234,44 @@ def styled_input(label: str) -> str: return input(in_color("blue", label)) -def get_login_id(server_properties: Dict[str, Any]) -> str: +def get_login_label(server_properties: ServerSettings) -> str: require_email_format_usernames = server_properties["require_email_format_usernames"] email_auth_enabled = server_properties["email_auth_enabled"] if not require_email_format_usernames and email_auth_enabled: - label = "Email or Username: " + return "Email or Username: " elif not require_email_format_usernames: - label = "Username: " + return "Username: " else: # TODO: Validate Email address - label = "Email: " + return "Email: " + + +class NotAZulipOrganizationError(Exception): + pass + - return styled_input(label) +def get_server_settings(realm_url: str) -> ServerSettings: + response = requests.get(url=f"{realm_url}/api/v1/server_settings") + if response.status_code != requests.codes.OK: + raise NotAZulipOrganizationError(realm_url) + return response.json() def get_api_key(realm_url: str) -> Optional[Tuple[str, str, str]]: from getpass import getpass - server_properties = requests.get(url=f"{realm_url}/api/v1/server_settings").json() + try: + server_properties = get_server_settings(realm_url) + except NotAZulipOrganizationError: + exit_with_error(f"No Zulip Organization found at {realm_url}.") # Assuming we connect to and get data from the server, use the realm_url it suggests # This avoids cases where there are redirects between http and https, for example preferred_realm_url = server_properties["realm_uri"] - login_id = get_login_id(server_properties) + login_id_label = get_login_label(server_properties) + login_id = styled_input(login_id_label) password = getpass(in_color("blue", "Password: ")) response = requests.post( @@ -271,36 +312,60 @@ def fetch_zuliprc(zuliprc_path: str) -> None: print(in_color("red", "\nIncorrect Email(or Username) or Password!\n")) login_data = get_api_key(realm_url) + zulip_key_path = os.path.join( + os.path.dirname(os.path.abspath(zuliprc_path)), "zulip_key" + ) + preferred_realm_url, login_id, api_key = login_data save_zuliprc_failure = _write_zuliprc( - zuliprc_path, - login_id=login_id, + to_path=zuliprc_path, + key_path=zulip_key_path, api_key=api_key, + login_id=login_id, server_url=preferred_realm_url, ) if not save_zuliprc_failure: - print(f"Generated API key saved at {zuliprc_path}") + print(f"Generated config file saved at {zuliprc_path}") else: exit_with_error(save_zuliprc_failure) def _write_zuliprc( - to_path: str, *, login_id: str, api_key: str, server_url: str + to_path: str, *, key_path: str, login_id: str, api_key: str, server_url: str ) -> str: """ - Writes a zuliprc file, returning a non-empty error string on failure - Only creates new private files; errors if file already exists + Writes both zuliprc and zulip_key files securely. + Ensures atomicity: if one file fails to write, cleans up the other. + Returns an empty string on success, or a descriptive error message on failure. """ + zuliprc_created = False + try: + # Write zuliprc with open( os.open(to_path, os.O_CREAT | os.O_WRONLY | os.O_EXCL, 0o600), "w" ) as f: - f.write(f"[api]\nemail={login_id}\nkey={api_key}\nsite={server_url}") + f.write( + f"[api]\nemail={login_id}\npasscmd=cat zulip_key\nsite={server_url}" + ) + zuliprc_created = True + # Write zulip_key + with open( + os.open(key_path, os.O_CREAT | os.O_WRONLY | os.O_EXCL, 0o600), "w" + ) as f: + f.write(api_key) + return "" + except FileExistsError: - return f"zuliprc already exists at {to_path}" + filename = to_path if not zuliprc_created else key_path + return f"FileExistsError: {filename} already exists" except OSError as ex: - return f"{ex.__class__.__name__}: zuliprc could not be created at {to_path}" + if zuliprc_created: + with contextlib.suppress(Exception): + os.remove(to_path) + filename = key_path if zuliprc_created else to_path + return f"{ex.__class__.__name__}: could not create {filename} ({ex})" def parse_zuliprc(zuliprc_str: str) -> Dict[str, SettingData]: @@ -326,12 +391,12 @@ def parse_zuliprc(zuliprc_str: str) -> Dict[str, SettingData]: in_color( "red", "ERROR: Please ensure your zuliprc is NOT publicly accessible:\n" - " {0}\n" - "(it currently has permissions '{1}')\n" + f" {zuliprc_path}\n" + f"(it currently has permissions '{stat.filemode(mode)}')\n" "This can often be achieved with a command such as:\n" - " chmod og-rwx {0}\n" + f" chmod og-rwx {zuliprc_path}\n" "Consider regenerating the [api] part of your zuliprc to ensure " - "your account is secure.".format(zuliprc_path, stat.filemode(mode)), + "your account is secure.", ) ) sys.exit(1) @@ -416,7 +481,11 @@ def main(options: Optional[List[str]] = None) -> None: else: zuliprc_path = "~/zuliprc" - print(f"Detected platform: {detected_platform()}") + print( + "Detected:" + f"\n - platform: {detected_platform()}" + f"\n - python: {detected_python_in_full()}" + ) try: zterm = parse_zuliprc(zuliprc_path) @@ -473,6 +542,11 @@ def main(options: Optional[List[str]] = None) -> None: theme_to_use = SettingData(real_theme_name, theme_to_use.source) ### Load overrides & validate remaining settings + if args.transparency: + zterm["transparency"] = SettingData( + args.transparency, ConfigSource.COMMANDLINE + ) + if args.autohide: zterm["autohide"] = SettingData(args.autohide, ConfigSource.COMMANDLINE) @@ -490,11 +564,11 @@ def main(options: Optional[List[str]] = None) -> None: ) # Validate remaining settings - for setting, valid_values in valid_remaining_settings.items(): - if zterm[setting].value not in valid_values: + for setting, valid_remaining_values in valid_remaining_settings.items(): + if zterm[setting].value not in valid_remaining_values: helper_text = ( ["Valid values are:"] - + [f" {option}" for option in valid_values] + + [f" {option}" for option in valid_remaining_values] + [f"Specify the {setting} option in zuliprc file."] ) exit_with_error( @@ -519,6 +593,7 @@ def print_setting(setting: str, data: SettingData, suffix: str = "") -> None: incomplete_theme_text += " (all themes are incomplete)" print(in_color("yellow", incomplete_theme_text)) print_setting("autohide setting", zterm["autohide"]) + print_setting("exit confirmation setting", zterm["exit_confirmation"]) if zterm["footlinks"].source == ConfigSource.ZULIPRC: print_setting( "maximum footlinks value", @@ -529,19 +604,40 @@ def print_setting(setting: str, data: SettingData, suffix: str = "") -> None: print_setting("maximum footlinks value", zterm["maximum-footlinks"]) print_setting("color depth setting", zterm["color-depth"]) print_setting("notify setting", zterm["notify"]) + print_setting("transparency setting", zterm["transparency"]) - ### Generate data not output to user, but into Controller - # Generate urwid palette - color_depth_str = zterm["color-depth"].value - color_depth = COLOR_DEPTH_ARGS_TO_DEPTHS[color_depth_str] + if zterm["editor"].source == ConfigSource.ZULIPRC: + editor_command = zterm["editor"].value + editor_config_source = ConfigSource.ZULIPRC + else: + editor_command = os.environ.get( + "ZULIP_EDITOR_COMMAND", + os.environ.get("EDITOR", ""), + ) + editor_config_source = ConfigSource.ENV - theme_data = generate_theme(theme_to_use.value, color_depth) + print_setting( + "external editor command", + SettingData(editor_command, editor_config_source), + ) + ### Generate data not output to user, but into Controller # Translate valid strings for boolean values into True/False boolean_settings: Dict[str, bool] = dict() for setting, valid_values in VALID_BOOLEAN_SETTINGS.items(): boolean_settings[setting] = zterm[setting].value == valid_values[0] + # Generate urwid palette + color_depth_str = zterm["color-depth"].value + color_depth = COLOR_DEPTH_ARGS_TO_DEPTHS[color_depth_str] + transparency_enabled = boolean_settings["transparency"] + + theme_data = generate_theme( + theme_to_use.value, + color_depth=color_depth, + transparent_background=transparency_enabled, + ) + Controller( config_file=zuliprc_path, maximum_footlinks=maximum_footlinks, @@ -551,13 +647,14 @@ def print_setting(setting: str, data: SettingData, suffix: str = "") -> None: in_explore_mode=args.explore, **boolean_settings, debug_path=debug_path, + editor_command=editor_command, ).main() except ServerConnectionFailure as e: # Acts as separator between logs zt_logger.info("\n\n%s\n\n", e) zt_logger.exception(e) exit_with_error(f"\nError connecting to Zulip server: {e}.") - except InvalidThemeColorCode as e: + except ThemeError as e: # Acts as separator between logs zt_logger.info("\n\n%s\n\n", e) zt_logger.exception(e) @@ -615,8 +712,8 @@ def print_setting(setting: str, data: SettingData, suffix: str = "") -> None: # Dump stats only after temporary file is closed (for Win NT+ case) prof.dump_stats(profile_path) print( - "Profile data saved to {0}.\n" - "You can visualize it using e.g. `snakeviz {0}`".format(profile_path) + f"Profile data saved to {profile_path}.\n" + f"You can visualize it using e.g. `snakeviz {profile_path}`" ) sys.exit(1) diff --git a/zulipterminal/config/color.py b/zulipterminal/config/color.py index 93333decef..b5a6cb5bf9 100644 --- a/zulipterminal/config/color.py +++ b/zulipterminal/config/color.py @@ -25,13 +25,18 @@ # fmt: off +# Background is treated as transparent by default +# This avoids the need for a dummy default color +class Background(Enum): + COLOR = 'default default default' + + # NOTE: The 24bit color codes use 256 color which can be # enhanced to be truly 24bit. # NOTE: The 256code format can be moved to h0-255 to # make use of the complete range instead of only 216 colors. class DefaultColor(Enum): # color = 16code 256code 24code - DEFAULT = 'default default default' BLACK = 'black g19 g19' DARK_RED = 'dark_red #a00 #a00' DARK_GREEN = 'dark_green #080 #080' diff --git a/zulipterminal/config/keys.py b/zulipterminal/config/keys.py index 62482b6c58..a86420a364 100644 --- a/zulipterminal/config/keys.py +++ b/zulipterminal/config/keys.py @@ -2,11 +2,11 @@ Keybindings and their helper functions """ -from collections import OrderedDict -from typing import List +from typing import Dict, List from typing_extensions import NotRequired, TypedDict from urwid.command_map import ( + ACTIVATE, CURSOR_DOWN, CURSOR_LEFT, CURSOR_MAX_RIGHT, @@ -26,389 +26,466 @@ class KeyBinding(TypedDict): # fmt: off -KEY_BINDINGS: 'OrderedDict[str, KeyBinding]' = OrderedDict([ +KEY_BINDINGS: Dict[str, KeyBinding] = { # Key that is displayed in the UI is determined by the method # primary_key_for_command. (Currently the first key in the list) - ('HELP', { + 'HELP': { 'keys': ['?'], - 'help_text': 'Show/hide help menu', + 'help_text': 'Show/hide Help Menu', 'excluded_from_random_tips': True, 'key_category': 'general', - }), - ('MARKDOWN_HELP', { + }, + 'MARKDOWN_HELP': { 'keys': ['meta m'], - 'help_text': 'Show/hide markdown help menu', + 'help_text': 'Show/hide Markdown Help Menu', 'key_category': 'general', - }), - ('ABOUT', { + }, + 'ABOUT': { 'keys': ['meta ?'], - 'help_text': 'Show/hide about menu', + 'help_text': 'Show/hide About Menu', 'key_category': 'general', - }), - ('GO_BACK', { - 'keys': ['esc'], - 'help_text': 'Go Back', - 'excluded_from_random_tips': False, - 'key_category': 'general', - }), - ('OPEN_DRAFT', { + }, + 'OPEN_DRAFT': { 'keys': ['d'], 'help_text': 'Open draft message saved in this session', + 'key_category': 'open_compose', + }, + 'COPY_ABOUT_INFO': { + 'keys': ['c'], + 'help_text': 'Copy information from About Menu to clipboard', + 'key_category': 'general', + }, + 'COPY_TRACEBACK': { + 'keys': ['c'], + 'help_text': 'Copy traceback from Exception Popup to clipboard', + 'excluded_from_random_tips': True, 'key_category': 'general', - }), - ('GO_UP', { + }, + 'EXIT_POPUP': { + 'keys': ['esc'], + 'help_text': 'Close popup', + 'key_category': 'navigation', + }, + 'GO_UP': { 'keys': ['up', 'k'], 'help_text': 'Go up / Previous message', 'key_category': 'navigation', - }), - ('GO_DOWN', { + }, + 'GO_DOWN': { 'keys': ['down', 'j'], 'help_text': 'Go down / Next message', 'key_category': 'navigation', - }), - ('GO_LEFT', { + }, + 'GO_LEFT': { 'keys': ['left', 'h'], 'help_text': 'Go left', 'key_category': 'navigation', - }), - ('GO_RIGHT', { + }, + 'GO_RIGHT': { 'keys': ['right', 'l'], 'help_text': 'Go right', 'key_category': 'navigation', - }), - ('SCROLL_UP', { + }, + 'SCROLL_UP': { 'keys': ['page up', 'K'], 'help_text': 'Scroll up', 'key_category': 'navigation', - }), - ('SCROLL_DOWN', { + }, + 'SCROLL_DOWN': { 'keys': ['page down', 'J'], 'help_text': 'Scroll down', 'key_category': 'navigation', - }), - ('GO_TO_BOTTOM', { + }, + 'GO_TO_BOTTOM': { 'keys': ['end', 'G'], 'help_text': 'Go to bottom / Last message', 'key_category': 'navigation', - }), - ('REPLY_MESSAGE', { + }, + 'ACTIVATE_BUTTON': { + 'keys': ['enter', ' '], + 'help_text': 'Trigger the selected entry', + 'key_category': 'navigation', + }, + 'REPLY_MESSAGE': { 'keys': ['r', 'enter'], 'help_text': 'Reply to the current message', - 'key_category': 'msg_actions', - }), - ('MENTION_REPLY', { + 'key_category': 'open_compose', + }, + 'MENTION_REPLY': { 'keys': ['@'], 'help_text': 'Reply mentioning the sender of the current message', - 'key_category': 'msg_actions', - }), - ('QUOTE_REPLY', { + 'key_category': 'open_compose', + }, + 'QUOTE_REPLY': { 'keys': ['>'], 'help_text': 'Reply quoting the current message text', - 'key_category': 'msg_actions', - }), - ('REPLY_AUTHOR', { + 'key_category': 'open_compose', + }, + 'REPLY_AUTHOR': { 'keys': ['R'], 'help_text': 'Reply directly to the sender of the current message', - 'key_category': 'msg_actions', - }), - ('EDIT_MESSAGE', { + 'key_category': 'open_compose', + }, + 'EDIT_MESSAGE': { 'keys': ['e'], 'help_text': "Edit message's content or topic", 'key_category': 'msg_actions' - }), - ('STREAM_MESSAGE', { + }, + 'STREAM_MESSAGE': { 'keys': ['c'], 'help_text': 'New message to a stream', - 'key_category': 'msg_actions', - }), - ('PRIVATE_MESSAGE', { + 'key_category': 'open_compose', + }, + 'PRIVATE_MESSAGE': { 'keys': ['x'], 'help_text': 'New message to a person or group of people', - 'key_category': 'msg_actions', - }), - ('CYCLE_COMPOSE_FOCUS', { + 'key_category': 'open_compose', + }, + 'CYCLE_COMPOSE_FOCUS': { 'keys': ['tab'], 'help_text': 'Cycle through recipient and content boxes', - 'key_category': 'msg_compose', - }), - ('SEND_MESSAGE', { + 'key_category': 'compose_box', + }, + 'SEND_MESSAGE': { 'keys': ['ctrl d', 'meta enter'], 'help_text': 'Send a message', - 'key_category': 'msg_compose', - }), - ('SAVE_AS_DRAFT', { + 'key_category': 'compose_box', + }, + 'SAVE_AS_DRAFT': { 'keys': ['meta s'], 'help_text': 'Save current message as a draft', - 'key_category': 'msg_compose', - }), - ('AUTOCOMPLETE', { + 'key_category': 'compose_box', + }, + 'AUTOCOMPLETE': { 'keys': ['ctrl f'], 'help_text': ('Autocomplete @mentions, #stream_names, :emoji:' ' and topics'), - 'key_category': 'msg_compose', - }), - ('AUTOCOMPLETE_REVERSE', { + 'key_category': 'compose_box', + }, + 'AUTOCOMPLETE_REVERSE': { 'keys': ['ctrl r'], 'help_text': 'Cycle through autocomplete suggestions in reverse', - 'key_category': 'msg_compose', - }), - ('ADD_REACTION', { + 'key_category': 'compose_box', + }, + 'ADD_REACTION': { 'keys': [':'], - 'help_text': 'Show/hide Emoji picker popup for current message', + 'help_text': 'Show/hide emoji picker for current message', 'key_category': 'msg_actions', - }), - ('STREAM_NARROW', { + }, + 'STREAM_NARROW': { 'keys': ['s'], - 'help_text': 'Narrow to the stream of the current message', - 'key_category': 'msg_actions', - }), - ('TOPIC_NARROW', { + 'help_text': 'View the stream of the current message', + 'key_category': 'narrowing', + }, + 'TOPIC_NARROW': { 'keys': ['S'], - 'help_text': 'Narrow to the topic of the current message', - 'key_category': 'msg_actions', - }), - ('NARROW_MESSAGE_RECIPIENT', { - 'keys': ['meta .'], - 'help_text': 'Narrow to compose box message recipient', - 'key_category': 'msg_compose', - }), - ('TOGGLE_NARROW', { + 'help_text': 'View the topic of the current message', + 'key_category': 'narrowing', + }, + 'TOGGLE_NARROW': { 'keys': ['z'], 'help_text': - 'Narrow to a topic/direct-chat, or stream/all-direct-messages', + "Zoom in/out the message's conversation context", + 'key_category': 'narrowing', + }, + 'NARROW_MESSAGE_RECIPIENT': { + 'keys': ['meta .'], + 'help_text': 'Switch message view to the compose box target', + 'key_category': 'narrowing', + }, + 'EXIT_COMPOSE': { + 'keys': ['esc'], + 'help_text': 'Exit message compose box', + 'key_category': 'compose_box', + }, + 'REACTION_AGREEMENT': { + 'keys': ['='], + 'help_text': 'Toggle first emoji reaction on selected message', 'key_category': 'msg_actions', - }), - ('TOGGLE_TOPIC', { + }, + 'TOGGLE_TOPIC': { 'keys': ['t'], 'help_text': 'Toggle topics in a stream', 'key_category': 'stream_list', - }), - ('ALL_MESSAGES', { + }, + 'ALL_MESSAGES': { 'keys': ['a', 'esc'], - 'help_text': 'Narrow to all messages', - 'key_category': 'navigation', - }), - ('ALL_PM', { + 'help_text': 'View all messages', + 'key_category': 'narrowing', + }, + 'ALL_PM': { 'keys': ['P'], - 'help_text': 'Narrow to all direct messages', - 'key_category': 'navigation', - }), - ('ALL_STARRED', { + 'help_text': 'View all direct messages', + 'key_category': 'narrowing', + }, + 'ALL_STARRED': { 'keys': ['f'], - 'help_text': 'Narrow to all starred messages', - 'key_category': 'navigation', - }), - ('ALL_MENTIONS', { + 'help_text': 'View all starred messages', + 'key_category': 'narrowing', + }, + 'ALL_MENTIONS': { 'keys': ['#'], - 'help_text': "Narrow to messages in which you're mentioned", - 'key_category': 'navigation', - }), - ('NEXT_UNREAD_TOPIC', { + 'help_text': "View all messages in which you're mentioned", + 'key_category': 'narrowing', + }, + 'NEXT_UNREAD_TOPIC': { 'keys': ['n'], 'help_text': 'Next unread topic', - 'key_category': 'navigation', - }), - ('NEXT_UNREAD_PM', { + 'key_category': 'narrowing', + }, + 'NEXT_UNREAD_PM': { 'keys': ['p'], 'help_text': 'Next unread direct message', - 'key_category': 'navigation', - }), - ('SEARCH_PEOPLE', { + 'key_category': 'narrowing', + }, + 'SEARCH_PEOPLE': { 'keys': ['w'], - 'help_text': 'Search Users', + 'help_text': 'Search users', 'key_category': 'searching', - }), - ('SEARCH_MESSAGES', { + }, + 'SEARCH_MESSAGES': { 'keys': ['/'], - 'help_text': 'Search Messages', + 'help_text': 'Search messages', 'key_category': 'searching', - }), - ('SEARCH_STREAMS', { + }, + 'SEARCH_STREAMS': { 'keys': ['q'], - 'help_text': 'Search Streams', + 'help_text': 'Search streams', 'key_category': 'searching', - }), - ('SEARCH_TOPICS', { + }, + 'SEARCH_TOPICS': { 'keys': ['q'], 'help_text': 'Search topics in a stream', 'key_category': 'searching', - }), - ('SEARCH_EMOJIS', { + }, + 'SEARCH_EMOJIS': { 'keys': ['p'], - 'help_text': 'Search emojis from Emoji-picker popup', + 'help_text': 'Search emojis from emoji picker', 'excluded_from_random_tips': True, 'key_category': 'searching', - }), - ('TOGGLE_MUTE_STREAM', { + }, + 'EXECUTE_SEARCH': { + 'keys': ['enter'], + 'help_text': 'Submit search and browse results', + 'key_category': 'searching', + }, + 'CLEAR_SEARCH': { + 'keys': ['esc'], + 'help_text': 'Clear search in current panel', + 'key_category': 'searching', + }, + 'TOGGLE_MUTE_STREAM': { 'keys': ['m'], - 'help_text': 'Mute/unmute Streams', + 'help_text': 'Mute/unmute streams', 'key_category': 'stream_list', - }), - ('ENTER', { - 'keys': ['enter'], - 'help_text': 'Perform current action', - 'key_category': 'navigation', - }), - ('THUMBS_UP', { + }, + 'THUMBS_UP': { 'keys': ['+'], - 'help_text': 'Add/remove thumbs-up reaction to the current message', + 'help_text': 'Toggle thumbs-up reaction to the current message', 'key_category': 'msg_actions', - }), - ('TOGGLE_STAR_STATUS', { + }, + 'TOGGLE_STAR_STATUS': { 'keys': ['ctrl s', '*'], - 'help_text': 'Add/remove star status of the current message', + 'help_text': 'Toggle star status of the current message', 'key_category': 'msg_actions', - }), - ('MSG_INFO', { + }, + 'MSG_INFO': { 'keys': ['i'], 'help_text': 'Show/hide message information', 'key_category': 'msg_actions', - }), - ('EDIT_HISTORY', { + }, + 'MSG_SENDER_INFO': { + 'keys': ['u'], + 'help_text': 'Show/hide message sender information', + 'key_category': 'msg_actions', + }, + 'EDIT_HISTORY': { 'keys': ['e'], - 'help_text': 'Show/hide edit history (from message information)', + 'help_text': 'Show/hide edit history', 'excluded_from_random_tips': True, - 'key_category': 'msg_actions', - }), - ('VIEW_IN_BROWSER', { + 'key_category': 'msg_info', + }, + 'VIEW_IN_BROWSER': { 'keys': ['v'], 'help_text': - 'View current message in browser (from message information)', + 'View current message in browser', 'excluded_from_random_tips': True, - 'key_category': 'msg_actions', - }), - ('STREAM_INFO', { + 'key_category': 'msg_info', + }, + 'STREAM_INFO': { 'keys': ['i'], 'help_text': 'Show/hide stream information & modify settings', 'key_category': 'stream_list', - }), - ('STREAM_MEMBERS', { + }, + 'STREAM_MEMBERS': { 'keys': ['m'], - 'help_text': 'Show/hide stream members (from stream information)', + 'help_text': 'Show/hide stream members', 'excluded_from_random_tips': True, - 'key_category': 'stream_list', - }), - ('COPY_STREAM_EMAIL', { + 'key_category': 'stream_info', + }, + 'COPY_STREAM_EMAIL': { 'keys': ['c'], 'help_text': - 'Copy stream email to clipboard (from stream information)', + 'Copy stream email to clipboard', 'excluded_from_random_tips': True, - 'key_category': 'stream_list', - }), - ('REDRAW', { + 'key_category': 'stream_info', + }, + 'REDRAW': { 'keys': ['ctrl l'], 'help_text': 'Redraw screen', 'key_category': 'general', - }), - ('QUIT', { + }, + 'QUIT': { 'keys': ['ctrl c'], 'help_text': 'Quit', 'key_category': 'general', - }), - ('USER_INFO', { + }, + 'USER_INFO': { 'keys': ['i'], - 'help_text': 'View user information (From Users list)', - 'key_category': 'general', - }), - ('BEGINNING_OF_LINE', { - 'keys': ['ctrl a'], - 'help_text': 'Jump to the beginning of line', - 'key_category': 'msg_compose', - }), - ('END_OF_LINE', { - 'keys': ['ctrl e'], - 'help_text': 'Jump to the end of line', - 'key_category': 'msg_compose', - }), - ('ONE_WORD_BACKWARD', { - 'keys': ['meta b'], - 'help_text': 'Jump backward one word', - 'key_category': 'msg_compose', - }), - ('ONE_WORD_FORWARD', { - 'keys': ['meta f'], - 'help_text': 'Jump forward one word', - 'key_category': 'msg_compose', - }), - ('DELETE_LAST_CHARACTER', { - 'keys': ['ctrl h'], - 'help_text': 'Delete previous character (to left)', - 'key_category': 'msg_compose', - }), - ('TRANSPOSE_CHARACTERS', { - 'keys': ['ctrl t'], - 'help_text': 'Transpose characters', - 'key_category': 'msg_compose', - }), - ('CUT_TO_END_OF_LINE', { + 'help_text': 'Show/hide user information', + 'key_category': 'user_list', + }, + 'NARROW_TO_USER_PM': { + # Added to clarify functionality of button activation, + # as opposed to opening user profile or other effects. + # Implementation uses ACTIVATE_BUTTON command. + 'keys': ['enter'], + 'help_text': 'Narrow to direct messages with user', + 'key_category': 'user_list', + }, + 'BEGINNING_OF_LINE': { + 'keys': ['ctrl a', 'home'], + 'help_text': 'Start of line', + 'key_category': 'editor_navigation', + }, + 'END_OF_LINE': { + 'keys': ['ctrl e', 'end'], + 'help_text': 'End of line', + 'key_category': 'editor_navigation', + }, + 'ONE_WORD_BACKWARD': { + 'keys': ['meta b', 'shift left'], + 'help_text': 'Start of current or previous word', + 'key_category': 'editor_navigation', + }, + 'ONE_WORD_FORWARD': { + 'keys': ['meta f', 'shift right'], + 'help_text': 'Start of next word', + 'key_category': 'editor_navigation', + }, + 'PREV_LINE': { + 'keys': ['up', 'ctrl p'], + 'help_text': 'Previous line', + 'key_category': 'editor_navigation', + }, + 'NEXT_LINE': { + 'keys': ['down', 'ctrl n'], + 'help_text': 'Next line', + 'key_category': 'editor_navigation', + }, + 'UNDO_LAST_ACTION': { + 'keys': ['ctrl _'], + 'help_text': 'Undo last action', + 'key_category': 'editor_text_manipulation', + }, + 'CLEAR_MESSAGE': { + 'keys': ['ctrl l'], + 'help_text': 'Clear text box', + 'key_category': 'editor_text_manipulation', + }, + 'CUT_TO_END_OF_LINE': { 'keys': ['ctrl k'], 'help_text': 'Cut forwards to the end of the line', - 'key_category': 'msg_compose', - }), - ('CUT_TO_START_OF_LINE', { + 'key_category': 'editor_text_manipulation', + }, + 'CUT_TO_START_OF_LINE': { 'keys': ['ctrl u'], 'help_text': 'Cut backwards to the start of the line', - 'key_category': 'msg_compose', - }), - ('CUT_TO_END_OF_WORD', { + 'key_category': 'editor_text_manipulation', + }, + 'CUT_TO_END_OF_WORD': { 'keys': ['meta d'], 'help_text': 'Cut forwards to the end of the current word', - 'key_category': 'msg_compose', - }), - ('CUT_TO_START_OF_WORD', { - 'keys': ['ctrl w'], + 'key_category': 'editor_text_manipulation', + }, + 'CUT_TO_START_OF_WORD': { + 'keys': ['ctrl w', 'meta backspace'], 'help_text': 'Cut backwards to the start of the current word', - 'key_category': 'msg_compose', - }), - ('PASTE_LAST_CUT', { + 'key_category': 'editor_text_manipulation', + }, + 'CUT_WHOLE_LINE': { + 'keys': ['meta x'], + 'help_text': 'Cut the current line', + 'key_category': 'editor_text_manipulation', + }, + 'PASTE_LAST_CUT': { 'keys': ['ctrl y'], 'help_text': 'Paste last cut section', - 'key_category': 'msg_compose', - }), - ('UNDO_LAST_ACTION', { - 'keys': ['ctrl _'], - 'help_text': 'Undo last action', - 'key_category': 'msg_compose', - }), - ('PREV_LINE', { - 'keys': ['up', 'ctrl p'], - 'help_text': 'Jump to the previous line', - 'key_category': 'msg_compose', - }), - ('NEXT_LINE', { - 'keys': ['down', 'ctrl n'], - 'help_text': 'Jump to the next line', - 'key_category': 'msg_compose', - }), - ('CLEAR_MESSAGE', { - 'keys': ['ctrl l'], - 'help_text': 'Clear compose box', - 'key_category': 'msg_compose', - }), - ('FULL_RENDERED_MESSAGE', { + 'key_category': 'editor_text_manipulation', + }, + 'DELETE_LAST_CHARACTER': { + 'keys': ['ctrl h'], + 'help_text': 'Delete previous character', + 'key_category': 'editor_text_manipulation', + }, + 'TRANSPOSE_CHARACTERS': { + 'keys': ['ctrl t'], + 'help_text': 'Swap with previous character', + 'key_category': 'editor_text_manipulation', + }, + 'NEW_LINE': { + # urwid_readline's command + # This obvious hotkey is added to clarify against 'enter' to send + # and to differentiate from other hotkeys using 'enter'. + 'keys': ['enter'], + 'help_text': 'Insert new line', + 'key_category': 'compose_box', + }, + 'OPEN_EXTERNAL_EDITOR': { + 'keys': ['ctrl o'], + 'help_text': 'Open an external editor to edit the message content', + 'key_category': 'compose_box', + }, + 'FULL_RENDERED_MESSAGE': { 'keys': ['f'], - 'help_text': 'Show/hide full rendered message (from message information)', - 'key_category': 'msg_actions', - }), - ('FULL_RAW_MESSAGE', { + 'help_text': 'Show/hide full rendered message', + 'key_category': 'msg_info', + }, + 'FULL_RAW_MESSAGE': { 'keys': ['r'], - 'help_text': 'Show/hide full raw message (from message information)', - 'key_category': 'msg_actions', - }), -]) + 'help_text': 'Show/hide full raw message', + 'key_category': 'msg_info', + }, + 'NEW_HINT': { + 'keys': ['tab'], + 'help_text': 'New footer hotkey hint', + 'key_category': 'general', + }, +} # fmt: on -HELP_CATEGORIES = OrderedDict( - [ - ("general", "General"), - ("navigation", "Navigation"), - ("searching", "Searching"), - ("msg_actions", "Message actions"), - ("stream_list", "Stream list actions"), - ("msg_compose", "Composing a Message"), - ] -) +HELP_CATEGORIES = { + "general": "General", + "navigation": "Navigation", + "narrowing": "Switching Messages View", + "searching": "Searching", + "msg_actions": "Message actions", + "stream_list": "Stream list actions", + "user_list": "User list actions", + "open_compose": "Begin composing a message", + "compose_box": "Writing a message", + "editor_navigation": "Editor: Navigation", + "editor_text_manipulation": "Editor: Text Manipulation", + "stream_info": ( + f"Stream information (press {KEY_BINDINGS['STREAM_INFO']['keys'][0]}" + f" to view info of a stream)" + ), + "msg_info": ( + f"Message information (press {KEY_BINDINGS['MSG_INFO']['keys'][0]}" + f" to view info of a message)" + ), +} ZT_TO_URWID_CMD_MAPPING = { "GO_UP": CURSOR_UP, @@ -418,6 +495,7 @@ class KeyBinding(TypedDict): "SCROLL_UP": CURSOR_PAGE_UP, "SCROLL_DOWN": CURSOR_PAGE_DOWN, "GO_TO_BOTTOM": CURSOR_MAX_RIGHT, + "ACTIVATE_BUTTON": ACTIVATE, } @@ -453,6 +531,47 @@ def primary_key_for_command(command: str) -> str: return keys_for_command(command).pop(0) +URWID_KEY_TO_DISPLAY_KEY_MAPPING = { + "page up": "PgUp", + "page down": "PgDn", +} + + +def display_key_for_urwid_key(urwid_key: str) -> str: + """ + Returns a displayable user-centric format of the urwid key. + """ + if urwid_key == " ": + return "Space" + + for urwid_map_key, display_map_key in URWID_KEY_TO_DISPLAY_KEY_MAPPING.items(): + if urwid_map_key in urwid_key: + urwid_key = urwid_key.replace(urwid_map_key, display_map_key) + display_key = [ + keyboard_key.capitalize() + if len(keyboard_key) > 1 and keyboard_key[0].islower() + else keyboard_key + for keyboard_key in urwid_key.split() + ] + return " ".join(display_key) + + +def display_keys_for_command(command: str) -> List[str]: + """ + Returns the user-friendly display keys for a given mapped command + """ + return [ + display_key_for_urwid_key(urwid_key) for urwid_key in keys_for_command(command) + ] + + +def primary_display_key_for_command(command: str) -> str: + """ + Primary Display Key is the formatted display version of the primary key + """ + return display_key_for_urwid_key(primary_key_for_command(command)) + + def commands_for_random_tips() -> List[KeyBinding]: """ Return list of commands which may be displayed as a random tip diff --git a/zulipterminal/config/symbols.py b/zulipterminal/config/symbols.py index 4870d9ca7a..f52dd85f7b 100644 --- a/zulipterminal/config/symbols.py +++ b/zulipterminal/config/symbols.py @@ -2,26 +2,87 @@ Terminal characters used to mark particular elements of the user interface """ -INVALID_MARKER = "✗" +# Unless otherwise noted, all symbols are in: +# - Basic Multilingual Plane (BMP) +# - Unicode v1.1 +# Suffix comments indicate: unicode name, codepoint (unicode block, version if not v1.1) + +INVALID_MARKER = "✗" # BALLOT X, U+2717 (Dingbats) + +ALL_MESSAGES_MARKER = "≡" # IDENTICAL TO, U+2261 (Mathematical operators) +MENTIONED_MESSAGES_MARKER = "@" +STARRED_MESSAGES_MARKER = "*" + +DIRECT_MESSAGE_MARKER = "§" # SECTION SIGN, U+00A7 (Latin-1 supplement) + STREAM_MARKER_PRIVATE = "P" STREAM_MARKER_PUBLIC = "#" -STREAM_MARKER_WEB_PUBLIC = "⊚" -STREAM_TOPIC_SEPARATOR = "▶" -# Used as a separator between messages and 'EDITED' -MESSAGE_CONTENT_MARKER = "▒" # Options are '█', '▓', '▒', '░' -QUOTED_TEXT_MARKER = "░" -MESSAGE_HEADER_DIVIDER = "━" -CHECK_MARK = "✓" -APPLICATION_TITLE_BAR_LINE = "═" -PINNED_STREAMS_DIVIDER = "-" -COLUMN_TITLE_BAR_LINE = "━" +STREAM_MARKER_WEB_PUBLIC = "⊚" # CIRCLED RING OPERATOR, U+229A (Mathematical operators) + +STREAM_TOPIC_SEPARATOR = "▶" # BLACK RIGHT-POINTING TRIANGLE, U+25B6 (Geometric shapes) + +# Range of block options for consideration: '█', '▓', '▒', '░' +# FULL BLOCK U+2588, DARK SHADE U+2593, MEDIUM SHADE U+2592, LIGHT SHADE U+2591 + +# Separator between messages and 'EDITED' +MESSAGE_CONTENT_MARKER = "▒" # MEDIUM SHADE, U+2592 (Block elements) + +QUOTED_TEXT_MARKER = "░" # LIGHT SHADE, U+2591 (Block elements) + +# Extends from end of recipient details (above messages where recipients differ above) +MESSAGE_HEADER_DIVIDER = "━" # BOX DRAWINGS HEAVY HORIZONTAL, U+2501 (Box drawing) + +# NOTE: CHECK_MARK is not used for resolved topics (that is an API detail) +CHECK_MARK = "✓" # CHECK MARK, U+2713 (Dingbats) + +APPLICATION_TITLE_BAR_LINE = "═" # BOX DRAWINGS DOUBLE HORIZONTAL, U+2550 (Box drawing) +PINNED_STREAMS_DIVIDER = "-" # HYPHEN-MINUS, U+002D (Basic latin) +COLUMN_TITLE_BAR_LINE = "━" # BOX DRAWINGS HEAVY HORIZONTAL, U+2501 (Box drawing) +COLUMN_DIVIDER_LINE = "│" # BOX DRAWINGS LIGHT VERTICAL, U+2502 (Box drawing) +SECTION_DIVIDER_LINE = "─" # BOX DRAWINGS LIGHT HORIZONTAL, U+2500 (Box drawing) + # NOTE: The '⏱' emoji needs an extra space while rendering. Otherwise, it # appears to overlap its subsequent text. -TIME_MENTION_MARKER = "⏱ " # Other tested options are: '⧗' and '⧖'. +# Other tested options are: '⧗' and '⧖'. +# TODO: Try 25F7, WHITE CIRCLE WITH UPPER RIGHT QUADRANT? +TIME_MENTION_MARKER = "⏱ " # STOPWATCH, U+23F1 (Misc Technical, Unicode 6.0) + MUTE_MARKER = "M" -STATUS_ACTIVE = "●" -STATUS_IDLE = "◒" -STATUS_OFFLINE = "○" -STATUS_INACTIVE = "•" -AUTOHIDE_TAB_LEFT_ARROW = "❰" -AUTOHIDE_TAB_RIGHT_ARROW = "❱" +STATUS_ACTIVE = "●" # BLACK CIRCLE, U+25CF (Geometric shapes) +STATUS_IDLE = "◒" # CIRCLE WITH LOWER HALF BLACK, U+25D2 (Geometric shapes) +STATUS_OFFLINE = "○" # WHITE CIRCLE, U+25CB (Geometric shapes) +STATUS_INACTIVE = "•" # BULLET, U+2022 (General punctuation) +BOT_MARKER = "♟" # BLACK CHESS PAWN, U+265F (Misc symbols) + +# Unicode 3.2: +AUTOHIDE_TAB_LEFT_ARROW = "❰" # HEAVY LEFT-POINTING ANGLE BRACKET ORNAMENT, U+2770 +AUTOHIDE_TAB_RIGHT_ARROW = "❱" # HEAVY RIGHT-POINTING ANGLE BRACKET ORNAMENT, U+2771 + +# All in Block elements: +POPUP_TOP_LINE = "▄" # LOWER HALF BLOCK, U+2584 +POPUP_CONTENT_BORDER = dict( + tlcorner="▛", # QUADRANT UPPER LEFT AND UPPER RIGHT AND LOWER LEFT, U+259B (v3.2) + tline="▀", # UPPER HALF BLOCK, U+2580 + trcorner="▜", # QUADRANT UPPER LEFT AND UPPER RIGHT AND LOWER RIGHT, U+259C (v3.2) + rline="▐", # RIGHT HALF BLOCK, U+2590 + lline="▌", # LEFT HALF BLOCK, U+258C + blcorner="▙", # QUADRANT UPPER LEFT AND LOWER LEFT AND LOWER RIGHT, U+2599 (v3.2) + bline="▄", # LOWER HALF BLOCK, U+2584 + brcorner="▟", # QUADRANT UPPER RIGHT AND LOWER LEFT AND LOWER RIGHT, U+259F (v3.2) +) + +COMPOSE_HEADER_TOP = "━" # BOX DRAWINGS HEAVY HORIZONTAL, U+2501 (Box drawing) +COMPOSE_HEADER_BOTTOM = "─" # BOX DRAWINGS LIGHT HORIZONTAL, U+2500 (Box drawing) + +_MESSAGE_RECIPIENTS_TOP = "─" # BOX DRAWINGS LIGHT HORIZONTAL, U+2500 (Box drawing) +_MESSAGE_RECIPIENTS_BOTTOM = _MESSAGE_RECIPIENTS_TOP +MESSAGE_RECIPIENTS_BORDER = dict( + tline=_MESSAGE_RECIPIENTS_TOP, + lline="", + trcorner=_MESSAGE_RECIPIENTS_TOP, + tlcorner=_MESSAGE_RECIPIENTS_TOP, + blcorner=_MESSAGE_RECIPIENTS_BOTTOM, + rline="", + bline=_MESSAGE_RECIPIENTS_BOTTOM, + brcorner=_MESSAGE_RECIPIENTS_BOTTOM, +) diff --git a/zulipterminal/config/themes.py b/zulipterminal/config/themes.py index 5872572adb..d6fe406c2f 100644 --- a/zulipterminal/config/themes.py +++ b/zulipterminal/config/themes.py @@ -1,12 +1,11 @@ """ Styles and their colour mappings in each theme, with helper functions """ - from typing import Any, Dict, List, Optional, Tuple, Union -from pygments.token import STANDARD_TYPES +from pygments.token import STANDARD_TYPES, _TokenType -from zulipterminal.config.color import term16 +from zulipterminal.config.color import Background, term16 from zulipterminal.themes import gruvbox_dark, gruvbox_light, zt_blue, zt_dark, zt_light @@ -35,6 +34,7 @@ 'user_idle' : '', 'user_offline' : '', 'user_inactive' : '', + 'user_bot' : '', 'title' : 'bold', 'column_title' : 'bold', 'time' : '', @@ -128,11 +128,27 @@ "white", ] +# These are style_translations for translating pygments styles into +# urwid-compatible styles +STYLE_TRANSLATIONS = { + " ": ",", + "italic": "italics", +} + -class InvalidThemeColorCode(Exception): +class ThemeError(Exception): pass +class InvalidThemeColorCode(ThemeError): + pass + + +class MissingThemeAttributeError(ThemeError): + def __init__(self, attribute: str) -> None: + super().__init__(f"Theme is missing required attribute '{attribute}'") + + def all_themes() -> List[str]: return list(THEMES.keys()) @@ -145,40 +161,78 @@ def complete_and_incomplete_themes() -> Tuple[List[str], List[str]]: complete = { name for name, theme in THEMES.items() + if getattr(theme, "Color", None) + if getattr(theme, "STYLES", None) if set(theme.STYLES) == set(REQUIRED_STYLES) - if set(theme.META) == set(REQUIRED_META) - for meta, conf in theme.META.items() - if set(conf) == set(REQUIRED_META.get(meta, {})) + if getattr(theme, "META", None) + if set(theme.META).issuperset(set(REQUIRED_META)) + for meta, conf in REQUIRED_META.items() + if set(conf) == set(theme.META.get(meta, {})) } incomplete = set(THEMES) - complete return sorted(complete), sorted(incomplete) -def generate_theme(theme_name: str, color_depth: int) -> ThemeSpec: - theme_styles = THEMES[theme_name].STYLES - validate_colors(theme_name, color_depth) - urwid_theme = parse_themefile(theme_styles, color_depth) +def generate_theme( + name: str, + *, + color_depth: int, + transparent_background: bool, +) -> ThemeSpec: + theme_module = THEMES[name] + + try: + theme_colors = theme_module.Color + except AttributeError: + raise MissingThemeAttributeError("Color") from None + validate_colors(theme_colors, color_depth) try: - theme_meta = THEMES[theme_name].META - add_pygments_style(theme_meta, urwid_theme) + theme_styles = theme_module.STYLES except AttributeError: - pass + raise MissingThemeAttributeError("STYLES") from None + + # META is not required, but if present should contain pygments data + theme_meta = getattr(theme_module, "META", None) + if theme_meta is not None: + # FIXME: Is META now required? Or only if Background.COLOR present? + # If used in styles and background is not specified, transparent by default! + background_color = theme_meta.get("background", Background.COLOR) + + pygments_data = theme_meta.get("pygments", None) + if pygments_data is None: + raise MissingThemeAttributeError('META["pygments"]') from None + for key in REQUIRED_META["pygments"]: + if pygments_data.get(key) is None: + raise MissingThemeAttributeError(f'META["pygments"]["{key}"]') from None + pygments_styles = generate_pygments_styles(pygments_data) + else: + background_color = Background.COLOR + pygments_styles = [] + + urwid_theme = parse_themefile( + theme_styles, + color_depth, + background_color, + transparent_background, + ) + urwid_theme.extend(pygments_styles) return urwid_theme -def validate_colors(theme_name: str, color_depth: int) -> None: +# color_enum can be one of many enums satisfying the specification +# There is currently no generic enum type +def validate_colors(color_enum: Any, color_depth: int) -> None: """ This function validates color-codes for a given theme, given colors are in `Color`. If any color is not in accordance with urwid default 16-color codes then the function raises InvalidThemeColorCode with the invalid colors. """ - theme_colors = THEMES[theme_name].Color failure_text = [] if color_depth == 16: - for color in theme_colors: + for color in color_enum: color_16code = color.value.split()[0] if color_16code not in valid_16_color_codes: invalid_16_color_code = str(color.name) @@ -187,17 +241,26 @@ def validate_colors(theme_name: str, color_depth: int) -> None: return else: text = "\n".join( - [f"Invalid 16-color codes in theme '{theme_name}':"] + failure_text + ["Invalid 16-color codes found in this theme:"] + failure_text ) raise InvalidThemeColorCode(text) def parse_themefile( - theme_styles: Dict[Optional[str], Tuple[Any, Any]], color_depth: int + theme_styles: Dict[Optional[str], Tuple[Any, Any]], + color_depth: int, + background_color: Any, + transparent_background: bool, ) -> ThemeSpec: urwid_theme = [] for style_name, (fg_name, bg_name) in theme_styles.items(): fg_code16, fg_code256, fg_code24, *fg_props = fg_name.value.split() + + # Background.COLOR is transparent + # => Replace with background_color, unless transparency is requested + if bg_name == Background.COLOR and not transparent_background: + bg_name = background_color # noqa: PLW2901 # overwrite loop variable + bg_code16, bg_code256, bg_code24, *bg_props = bg_name.value.split() new_style: StyleSpec @@ -223,7 +286,20 @@ def parse_themefile( return urwid_theme -def add_pygments_style(theme_meta: Dict[str, Any], urwid_theme: ThemeSpec) -> None: +def generate_urwid_compatible_pygments_styles( + pygments_styles: Dict[_TokenType, str], + style_translations: Dict[str, str] = STYLE_TRANSLATIONS, +) -> Dict[_TokenType, str]: + urwid_compatible_styles = {} + for token, style in pygments_styles.items(): + updated_style = style + for old_value, new_value in style_translations.items(): + updated_style = updated_style.replace(old_value, new_value) + urwid_compatible_styles[token] = updated_style + return urwid_compatible_styles + + +def generate_pygments_styles(pygments: Dict[str, Any]) -> ThemeSpec: """ This function adds pygments styles for use in syntax highlighting of code blocks and inline code. @@ -238,7 +314,6 @@ def add_pygments_style(theme_meta: Dict[str, Any], urwid_theme: ThemeSpec) -> No used to override certain pygments styles to match to urwid format. It can also be used to customize the syntax style. """ - pygments = theme_meta["pygments"] pygments_styles = pygments["styles"] pygments_bg = pygments["background"] pygments_overrides = pygments["overrides"] @@ -246,6 +321,9 @@ def add_pygments_style(theme_meta: Dict[str, Any], urwid_theme: ThemeSpec) -> No term16_styles = term16.styles term16_bg = term16.background_color + theme_styles_from_pygments: ThemeSpec = [] + pygments_styles = generate_urwid_compatible_pygments_styles(pygments_styles) + for token, css_class in STANDARD_TYPES.items(): if css_class in pygments_overrides: pygments_styles[token] = pygments_overrides[css_class] @@ -274,4 +352,5 @@ def add_pygments_style(theme_meta: Dict[str, Any], urwid_theme: ThemeSpec) -> No pygments_styles[token], pygments_bg, ) - urwid_theme.append(new_style) + theme_styles_from_pygments.append(new_style) + return theme_styles_from_pygments diff --git a/zulipterminal/config/ui_mappings.py b/zulipterminal/config/ui_mappings.py index 22d6092dca..8f8392c1f8 100644 --- a/zulipterminal/config/ui_mappings.py +++ b/zulipterminal/config/ui_mappings.py @@ -4,10 +4,9 @@ from typing import Dict -from typing_extensions import Literal - from zulipterminal.api_types import EditPropagateMode from zulipterminal.config.symbols import ( + BOT_MARKER, STATUS_ACTIVE, STATUS_IDLE, STATUS_INACTIVE, @@ -16,6 +15,7 @@ STREAM_MARKER_PUBLIC, STREAM_MARKER_WEB_PUBLIC, ) +from zulipterminal.helper import StreamAccessType, UserStatus EDIT_MODE_CAPTIONS: Dict[EditPropagateMode, str] = { @@ -24,19 +24,18 @@ "change_all": "Also change previous and following messages to this topic", } - # Mapping that binds user activity status to corresponding markers. -STATE_ICON = { +# NOTE: Ordering of keys affects display order +STATE_ICON: Dict[UserStatus, str] = { "active": STATUS_ACTIVE, "idle": STATUS_IDLE, "offline": STATUS_OFFLINE, "inactive": STATUS_INACTIVE, + "bot": BOT_MARKER, } -StreamAccessType = Literal["public", "private", "web-public"] - -STREAM_ACCESS_TYPE = { +STREAM_ACCESS_TYPE: Dict[StreamAccessType, Dict[str, str]] = { "public": {"description": "Public", "icon": STREAM_MARKER_PUBLIC}, "private": {"description": "Private", "icon": STREAM_MARKER_PRIVATE}, "web-public": {"description": "Web public", "icon": STREAM_MARKER_WEB_PUBLIC}, diff --git a/zulipterminal/config/ui_sizes.py b/zulipterminal/config/ui_sizes.py index 3a2e021e74..cdc95a7433 100644 --- a/zulipterminal/config/ui_sizes.py +++ b/zulipterminal/config/ui_sizes.py @@ -3,7 +3,7 @@ """ TAB_WIDTH = 3 -LEFT_WIDTH = 29 +LEFT_WIDTH = 31 RIGHT_WIDTH = 23 # These affect popup width-scaling, dependent upon window width diff --git a/zulipterminal/core.py b/zulipterminal/core.py index 656a7c9587..61c5f79922 100644 --- a/zulipterminal/core.py +++ b/zulipterminal/core.py @@ -8,7 +8,6 @@ import sys import time import webbrowser -from collections import OrderedDict from functools import partial from platform import platform from types import TracebackType @@ -20,6 +19,8 @@ from typing_extensions import Literal from zulipterminal.api_types import Composition, Message +from zulipterminal.config.keys import primary_display_key_for_command +from zulipterminal.config.symbols import POPUP_CONTENT_BORDER, POPUP_TOP_LINE from zulipterminal.config.themes import ThemeSpec from zulipterminal.config.ui_sizes import ( MAX_LINEAR_SCALING_WIDTH, @@ -35,6 +36,7 @@ EditHistoryView, EditModeView, EmojiPickerView, + ExceptionView, FullRawMsgView, FullRenderedMsgView, HelpView, @@ -51,6 +53,8 @@ ExceptionInfo = Tuple[Type[BaseException], BaseException, TracebackType] +SCROLL_PROMPT = "(up/down scrolls)" + class Controller: """ @@ -67,17 +71,23 @@ def __init__( theme: ThemeSpec, color_depth: int, debug_path: Optional[str], + editor_command: str, in_explore_mode: bool, + transparency: bool, autohide: bool, notify: bool, + exit_confirmation: bool, ) -> None: self.theme_name = theme_name self.theme = theme self.color_depth = color_depth self.in_explore_mode = in_explore_mode + self.transparency_enabled = transparency self.autohide = autohide + self.exit_confirmation = exit_confirmation self.notify_enabled = notify self.maximum_footlinks = maximum_footlinks + self.editor_command = editor_command self.debug_path = debug_path @@ -107,7 +117,12 @@ def __init__( self._exception_pipe = self.loop.watch_pipe(self._raise_exception) # Register new ^C handler - signal.signal(signal.SIGINT, self.exit_handler) + signal.signal( + signal.SIGINT, + self.prompting_exit_handler + if self.exit_confirmation + else self.no_prompt_exit_handler, + ) def raise_exception_in_main_thread( self, exc_info: ExceptionInfo, *, critical: bool @@ -119,16 +134,8 @@ def raise_exception_in_main_thread( # Exceptions shouldn't occur before the pipe is set assert hasattr(self, "_exception_pipe") - if isinstance(exc_info, tuple): - self._exception_info = exc_info - self._critical_exception = critical - else: - self._exception_info = ( - RuntimeError, - f"Invalid cross-thread exception info '{exc_info}'", - None, - ) - self._critical_exception = True + self._exception_info = exc_info + self._critical_exception = critical os.write(self._exception_pipe, b"1") def is_in_editor_mode(self) -> bool: @@ -221,32 +228,14 @@ def clamp(n: int, minn: int, maxn: int) -> int: return max_popup_cols, max_popup_rows def show_pop_up(self, to_show: Any, style: str) -> None: - border_lines = dict( - tlcorner="▛", - tline="▀", - trcorner="▜", - rline="▐", - lline="▌", - blcorner="▙", - bline="▄", - brcorner="▟", - ) text = urwid.Text(to_show.title, align="center") title_map = urwid.AttrMap(urwid.Filler(text), style) title_box_adapter = urwid.BoxAdapter(title_map, height=1) - title_box = urwid.LineBox( - title_box_adapter, - tlcorner="▄", - tline="▄", - trcorner="▄", - rline="", - lline="", - blcorner="", - bline="", - brcorner="", - ) - title = urwid.AttrMap(title_box, "popup_border") - content = urwid.LineBox(to_show, **border_lines) + title_top = urwid.AttrMap(urwid.Divider(POPUP_TOP_LINE), "popup_border") + title = urwid.Pile([title_top, title_box_adapter]) + + content = urwid.LineBox(to_show, **POPUP_CONTENT_BORDER) + self.loop.widget = urwid.Overlay( urwid.AttrMap(urwid.Frame(header=title, body=content), "popup_border"), self.view, @@ -265,11 +254,11 @@ def exit_popup(self) -> None: self.loop.widget = self.view def show_help(self) -> None: - help_view = HelpView(self, "Help Menu (up/down scrolls)") + help_view = HelpView(self, f"Help Menu {SCROLL_PROMPT}") self.show_pop_up(help_view, "area:help") def show_markdown_help(self) -> None: - markdown_view = MarkdownHelpView(self, "Markdown Help Menu (up/down scrolls)") + markdown_view = MarkdownHelpView(self, f"Markdown Help Menu {SCROLL_PROMPT}") self.show_pop_up(markdown_view, "area:help") def show_topic_edit_mode(self, button: Any) -> None: @@ -278,14 +267,14 @@ def show_topic_edit_mode(self, button: Any) -> None: def show_msg_info( self, msg: Message, - topic_links: "OrderedDict[str, Tuple[str, int, bool]]", - message_links: "OrderedDict[str, Tuple[str, int, bool]]", + topic_links: Dict[str, Tuple[str, int, bool]], + message_links: Dict[str, Tuple[str, int, bool]], time_mentions: List[Tuple[str, str]], ) -> None: msg_info_view = MsgInfoView( self, msg, - "Message Information (up/down scrolls)", + f"Message Information {SCROLL_PROMPT}", topic_links, message_links, time_mentions, @@ -310,9 +299,14 @@ def show_stream_members(self, stream_id: int) -> None: stream_members_view = StreamMembersView(self, stream_id) self.show_pop_up(stream_members_view, "area:stream") - def popup_with_message(self, text: str, width: int) -> None: + def show_popup_with_message(self, text: str, width: int) -> None: self.show_pop_up(NoticeView(self, text, width, "NOTICE"), "area:error") + def show_exception_popup(self, text: str, width: int, traceback: str) -> None: + self.show_pop_up( + ExceptionView(self, text, width, "EXCEPTION", traceback), "area:error" + ) + def show_about(self) -> None: self.show_pop_up( AboutView( @@ -326,21 +320,39 @@ def show_about(self) -> None: notify_enabled=self.notify_enabled, autohide_enabled=self.autohide, maximum_footlinks=self.maximum_footlinks, + exit_confirmation_enabled=self.exit_confirmation, + transparency_enabled=self.transparency_enabled, ), "area:help", ) def show_user_info(self, user_id: int) -> None: self.show_pop_up( - UserInfoView(self, user_id, "User Information (up/down scrolls)"), + UserInfoView( + self, + user_id, + f"User Information {SCROLL_PROMPT}", + "USER_INFO", + ), + "area:user", + ) + + def show_msg_sender_info(self, user_id: int) -> None: + self.show_pop_up( + UserInfoView( + self, + user_id, + f"Message Sender Information {SCROLL_PROMPT}", + "MSG_SENDER_INFO", + ), "area:user", ) def show_full_rendered_message( self, message: Message, - topic_links: "OrderedDict[str, Tuple[str, int, bool]]", - message_links: "OrderedDict[str, Tuple[str, int, bool]]", + topic_links: Dict[str, Tuple[str, int, bool]], + message_links: Dict[str, Tuple[str, int, bool]], time_mentions: List[Tuple[str, str]], ) -> None: self.show_pop_up( @@ -350,7 +362,7 @@ def show_full_rendered_message( topic_links, message_links, time_mentions, - "Full rendered message (up/down scrolls)", + f"Full rendered message {SCROLL_PROMPT}", ), "area:msg", ) @@ -358,8 +370,8 @@ def show_full_rendered_message( def show_full_raw_message( self, message: Message, - topic_links: "OrderedDict[str, Tuple[str, int, bool]]", - message_links: "OrderedDict[str, Tuple[str, int, bool]]", + topic_links: Dict[str, Tuple[str, int, bool]], + message_links: Dict[str, Tuple[str, int, bool]], time_mentions: List[Tuple[str, str]], ) -> None: self.show_pop_up( @@ -369,7 +381,7 @@ def show_full_raw_message( topic_links, message_links, time_mentions, - "Full raw message (up/down scrolls)", + f"Full raw message {SCROLL_PROMPT}", ), "area:msg", ) @@ -377,8 +389,8 @@ def show_full_raw_message( def show_edit_history( self, message: Message, - topic_links: "OrderedDict[str, Tuple[str, int, bool]]", - message_links: "OrderedDict[str, Tuple[str, int, bool]]", + topic_links: Dict[str, Tuple[str, int, bool]], + message_links: Dict[str, Tuple[str, int, bool]], time_mentions: List[Tuple[str, str]], ) -> None: self.show_pop_up( @@ -388,7 +400,7 @@ def show_edit_history( topic_links, message_links, time_mentions, - "Edit History (up/down scrolls)", + f"Edit History {SCROLL_PROMPT}", ), "area:msg", ) @@ -419,11 +431,14 @@ def open_in_browser(self, url: str) -> None: # Suppress stdout and stderr when opening browser with suppress_output(): browser_controller.open(url) + + # MacOS using Python version < 3.11 has no "name" attribute + # - https://github.com/python/cpython/issues/82828 + # - https://github.com/python/cpython/issues/87590 + # Use a default value if missing, for macOS, and potential later issues + browser_name = getattr(browser_controller, "name", "default browser") self.report_success( - [ - "The link was successfully opened using " - f"{browser_controller.name}" - ] + [f"The link was successfully opened using {browser_name}"] ) except webbrowser.Error as e: # Set a footer text if no runnable browser is located @@ -512,10 +527,17 @@ def search_messages(self, text: str) -> None: def save_draft_confirmation_popup(self, draft: Composition) -> None: question = urwid.Text( - "Save this message as a draft? (This will overwrite the existing draft.)" + ( + "bold", + "Save this message as a draft? " + "(This will overwrite the existing draft.)", + ), + "center", ) save_draft = partial(self.model.save_draft, draft) - self.loop.widget = PopUpConfirmationView(self, question, save_draft) + self.loop.widget = PopUpConfirmationView( + self, question, save_draft, location="center" + ) def stream_muting_confirmation_popup( self, stream_id: int, stream_name: str @@ -529,6 +551,21 @@ def stream_muting_confirmation_popup( mute_this_stream = partial(self.model.toggle_stream_muted_status, stream_id) self.loop.widget = PopUpConfirmationView(self, question, mute_this_stream) + def exit_compose_confirmation_popup(self) -> None: + question = urwid.Text( + ( + "bold", + "Please confirm that you wish to exit the compose box.\n" + "(You can save the message as a draft upon returning to compose)", + ), + "center", + ) + write_box = self.view.write_box + popup_view = PopUpConfirmationView( + self, question, write_box.exit_compose_box, location="center" + ) + self.loop.widget = popup_view + def copy_to_clipboard(self, text: str, text_category: str) -> None: try: pyperclip.copy(text) @@ -572,9 +609,9 @@ def _narrow_to(self, anchor: Optional[int], **narrow: Any) -> None: w_list = create_msg_box_list(self.model, msg_id_list, focus_msg_id=anchor) focus_position = self.model.get_focus_in_current_narrow() - if focus_position == set(): # No available focus; set to end + if focus_position is None: # No available focus; set to end focus_position = len(w_list) - 1 - assert not isinstance(focus_position, set) + assert focus_position is not None self.view.message_view.log.clear() if 0 <= focus_position < len(w_list): @@ -632,10 +669,21 @@ def narrow_to_all_mentions(self) -> None: def deregister_client(self) -> None: queue_id = self.model.queue_id self.client.deregister(queue_id, 1.0) + sys.exit(0) - def exit_handler(self, signum: int, frame: Any) -> None: + def no_prompt_exit_handler(self, signum: int, frame: Any) -> None: self.deregister_client() - sys.exit(0) + + def prompting_exit_handler(self, signum: int, frame: Any) -> None: + question = urwid.Text( + ("bold", " Please confirm that you wish to exit Zulip-Terminal "), + "center", + ) + popup_view = PopUpConfirmationView( + self, question, self.deregister_client, location="center" + ) + self.loop.widget = popup_view + self.loop.run() def _raise_exception(self, *args: Any, **kwargs: Any) -> Literal[True]: if self._exception_info is not None: @@ -650,26 +698,32 @@ def _raise_exception(self, *args: Any, **kwargs: Any) -> Literal[True]: traceback.print_exception(*exc, file=logfile) message = ( "An exception occurred:" - + "\n\n" - + "".join(traceback.format_exception_only(exc[0], exc[1])) - + "\n" - + "The application should continue functioning, but you " - + "may notice inconsistent behavior in this session." - + "\n\n" - + "Please report this to us either in" - + "\n" - + "* the #zulip-terminal stream" - + "\n" - + " (https://chat.zulip.org/#narrow/stream/" - + "206-zulip-terminal in the webapp)" - + "\n" - + "* an issue at " - + "https://github.com/zulip/zulip-terminal/issues" - + "\n\n" - + "Details of the exception can be found in " - + exception_logfile + "\n\n" + f"{''.join(traceback.format_exception_only(exc[0], exc[1]))}" + "\n" + "The application should continue functioning, but you " + "may notice inconsistent behavior in this session." + "\n\n" + "Please report this to us either in" + "\n" + "* the #zulip-terminal stream" + "\n" + " (https://chat.zulip.org/#narrow/stream/" + "206-zulip-terminal in the webapp)" + "\n" + "* an issue at " + "https://github.com/zulip/zulip-terminal/issues" + "\n\n" + "When reporting, it would be helpful to provide details of the " + "exception beyond the quick summary above, which you can obtain:" + "\n" + f"* from the end of '{exception_logfile}'" + "\n" + "* by copying the details to the clipboard now " + f"(press [{primary_display_key_for_command('COPY_TRACEBACK')}])" ) - self.popup_with_message(message, width=80) + full_traceback = "".join(traceback.format_exception(*exc)) + self.show_exception_popup(message, traceback=full_traceback, width=80) self._exception_info = None return True # If don't raise, retain pipe diff --git a/zulipterminal/helper.py b/zulipterminal/helper.py index 3bb5554fd6..a71d055b74 100644 --- a/zulipterminal/helper.py +++ b/zulipterminal/helper.py @@ -5,7 +5,7 @@ import os import subprocess import time -from collections import OrderedDict, defaultdict +from collections import defaultdict from contextlib import contextmanager from functools import partial, wraps from itertools import chain, combinations @@ -30,16 +30,15 @@ from urllib.parse import unquote import requests -from typing_extensions import ParamSpec, TypedDict +from typing_extensions import Literal, ParamSpec, TypedDict from zulipterminal.api_types import Composition, EmojiType, Message -from zulipterminal.config.keys import primary_key_for_command +from zulipterminal.config.keys import primary_display_key_for_command from zulipterminal.config.regexes import ( REGEX_COLOR_3_DIGIT, REGEX_COLOR_6_DIGIT, REGEX_QUOTED_FENCE_LENGTH, ) -from zulipterminal.config.ui_mappings import StreamAccessType from zulipterminal.platform_code import ( PLATFORM, normalized_file_path, @@ -47,6 +46,9 @@ ) +StreamAccessType = Literal["public", "private", "web-public"] + + class StreamData(TypedDict): name: str id: int @@ -64,6 +66,13 @@ class EmojiData(TypedDict): NamedEmojiData = Dict[str, EmojiData] +class CustomProfileData(TypedDict): + label: str + value: Union[str, List[int]] + type: int + order: int + + class TidiedUserInfo(TypedDict): full_name: str email: str @@ -71,6 +80,7 @@ class TidiedUserInfo(TypedDict): timezone: str role: int last_active: str + custom_profile_data: List[CustomProfileData] is_bot: bool # Below fields are only meaningful if is_bot == True @@ -78,8 +88,18 @@ class TidiedUserInfo(TypedDict): bot_owner_name: str +UserStatus = Literal["active", "idle", "offline", "inactive", "bot"] + + +class MinimalUserData(TypedDict): + full_name: str + email: str + user_id: int + status: UserStatus + + class Index(TypedDict): - pointer: Dict[str, Union[int, Set[None]]] # narrow_str, message_id + pointer: Dict[str, Optional[int]] # narrow_str, message_id (or no data) # Various sets of downloaded message ids (all, starred, ...) all_msg_ids: Set[int] starred_msg_ids: Set[int] @@ -97,7 +117,7 @@ class Index(TypedDict): initial_index = Index( - pointer=defaultdict(set), + pointer=dict(), all_msg_ids=set(), starred_msg_ids=set(), mentioned_msg_ids=set(), @@ -145,6 +165,20 @@ def wrapper(*args: ParamT.args, **kwargs: ParamT.kwargs) -> None: return wrapper +def sort_unread_topics( + unread_topics: Dict[Tuple[int, str], int], stream_list: List[int] +) -> List[Tuple[int, str]]: + return sorted( + unread_topics.keys(), + key=lambda stream_topic: ( + stream_list.index(stream_topic[0]) + if stream_topic[0] in stream_list + else len(stream_list), + stream_topic[1], + ), + ) + + def _set_count_in_model( new_count: int, changed_messages: List[Message], unread_counts: UnreadCounts ) -> None: @@ -423,7 +457,7 @@ def index_messages(messages: List[Message], model: Any, index: Index) -> Index: {recipient["id"] for recipient in msg["display_recipient"]} ) - if narrow[0][0] == "pm_with": + if narrow[0][0] == "pm-with": narrow_emails = [ model.user_dict[email]["user_id"] for email in narrow[0][1].split(", ") @@ -535,9 +569,20 @@ def match_emoji(emoji: str, text: str) -> bool: def match_topics(topic_names: List[str], search_text: str) -> List[str]: - return [ - name for name in topic_names if name.lower().startswith(search_text.lower()) - ] + matching_topics = [] + delimiters = "-_/" + trans = str.maketrans(delimiters, len(delimiters) * " ") + for full_topic_name in topic_names: + # "abc def-gh" --> ["abc def gh", "def", "gh"] + words_to_be_matched = [full_topic_name] + full_topic_name.translate( + trans + ).split()[1:] + + for word in words_to_be_matched: + if word.lower().startswith(search_text.lower()): + matching_topics.append(full_topic_name) + break + return matching_topics DataT = TypeVar("DataT") @@ -572,14 +617,10 @@ def match_stream( for datum, stream_name in data ] - matches: "OrderedDict[str, DefaultDict[int, List[Tuple[DataT, str]]]]" = ( - OrderedDict( - [ - ("pinned", defaultdict(list)), - ("unpinned", defaultdict(list)), - ] - ) - ) + matches: Dict[str, DefaultDict[int, List[Tuple[DataT, str]]]] = { + "pinned": defaultdict(list), + "unpinned": defaultdict(list), + } for datum, splits in stream_splits: stream_name = splits[0] @@ -659,7 +700,7 @@ def check_narrow_and_notify( and current_narrow != outer_narrow and current_narrow != inner_narrow ): - key = primary_key_for_command("NARROW_MESSAGE_RECIPIENT") + key = primary_display_key_for_command("NARROW_MESSAGE_RECIPIENT") controller.report_success( [ @@ -682,7 +723,7 @@ def notify_if_message_sent_outside_narrow( recipient_emails = [ controller.model.user_id_email_dict[user_id] for user_id in message["to"] ] - pm_with_narrow = [["pm_with", ", ".join(recipient_emails)]] + pm_with_narrow = [["pm-with", ", ".join(recipient_emails)]] check_narrow_and_notify(pm_narrow, pm_with_narrow, controller) @@ -743,6 +784,7 @@ def process_media(controller: Any, link: str) -> None: controller.view.set_footer_text, "Downloading your media..." ) media_path = download_media(controller, link, show_download_status) + media_path = normalized_file_path(media_path) tool = "" # TODO: Add support for other platforms as well. @@ -784,9 +826,7 @@ def download_media( show_download_status() controller.report_success([" Downloaded ", ("bold", media_name)]) - return normalized_file_path(local_path) - - return "" + return local_path @asynch diff --git a/zulipterminal/model.py b/zulipterminal/model.py index b99df970e7..1b8064fa87 100644 --- a/zulipterminal/model.py +++ b/zulipterminal/model.py @@ -2,9 +2,10 @@ Defines the `Model`, fetching and storing data retrieved from the Zulip server """ +import itertools import json import time -from collections import OrderedDict, defaultdict +from collections import defaultdict from concurrent.futures import Future, ThreadPoolExecutor, wait from copy import deepcopy from datetime import datetime @@ -26,33 +27,49 @@ import zulip from bs4 import BeautifulSoup -from typing_extensions import Literal, TypedDict +from typing_extensions import TypedDict from zulipterminal import unicode_emojis from zulipterminal.api_types import ( + MAX_MESSAGE_LENGTH, + MAX_STREAM_NAME_LENGTH, + MAX_TOPIC_NAME_LENGTH, + PRESENCE_OFFLINE_THRESHOLD_SECS, + PRESENCE_PING_INTERVAL_SECS, + TYPING_STARTED_EXPIRY_PERIOD, + TYPING_STARTED_WAIT_PERIOD, + TYPING_STOPPED_WAIT_PERIOD, Composition, + CustomFieldValue, + DirectTypingNotification, EditPropagateMode, Event, + Message, + MessagesFlagChange, PrivateComposition, PrivateMessageUpdateRequest, + ReactionEvent, RealmEmojiData, RealmUser, StreamComposition, StreamMessageUpdateRequest, Subscription, + SubscriptionSettingChange, + TypingStatusChange, + UpdateMessageContentEvent, + UpdateMessagesLocationEvent, ) -from zulipterminal.config.keys import primary_key_for_command +from zulipterminal.config.keys import primary_display_key_for_command from zulipterminal.config.symbols import STREAM_TOPIC_SEPARATOR -from zulipterminal.config.ui_mappings import ( - EDIT_TOPIC_POLICY, - ROLE_BY_ID, - StreamAccessType, -) +from zulipterminal.config.ui_mappings import EDIT_TOPIC_POLICY, ROLE_BY_ID, STATE_ICON from zulipterminal.helper import ( - Message, + CustomProfileData, + MinimalUserData, NamedEmojiData, + StreamAccessType, StreamData, TidiedUserInfo, + UserStatus, asynch, canonicalize_color, classify_unread_counts, @@ -61,22 +78,12 @@ initial_index, notify_if_message_sent_outside_narrow, set_count, + sort_unread_topics, ) from zulipterminal.platform_code import notify from zulipterminal.ui_tools.utils import create_msg_box_list -OFFLINE_THRESHOLD_SECS = 140 - -# Adapted from zerver/models.py -# These fields have migrated to the API inside the Realm object -# in ZFL 53. To allow backporting to earlier server versions, we -# define these hard-coded parameters. -MAX_STREAM_NAME_LENGTH = 60 -MAX_TOPIC_NAME_LENGTH = 60 -MAX_MESSAGE_LENGTH = 10000 - - class ServerConnectionFailure(Exception): pass @@ -108,7 +115,7 @@ def __init__(self, controller: Any) -> None: self.stream_id: Optional[int] = None self.recipients: FrozenSet[Any] = frozenset() self.index = initial_index - self._last_unread_topic = None + self.last_unread_pm = None self.user_id = -1 self.user_email = "" @@ -135,30 +142,27 @@ def __init__(self, controller: Any) -> None: "update_display_settings", "user_settings", "realm_emoji", + "custom_profile_fields", # zulip_version and zulip_feature_level are always returned in # POST /register from Feature level 3. "zulip_version", ] # Events desired with their corresponding callback - self.event_actions: "OrderedDict[str, Callable[[Event], None]]" = OrderedDict( - [ - ("message", self._handle_message_event), - ("update_message", self._handle_update_message_event), - ("reaction", self._handle_reaction_event), - ("subscription", self._handle_subscription_event), - ("typing", self._handle_typing_event), - ("update_message_flags", self._handle_update_message_flags_event), - ( - "update_global_notifications", - self._handle_update_global_notifications_event, - ), - ("update_display_settings", self._handle_update_display_settings_event), - ("user_settings", self._handle_user_settings_event), - ("realm_emoji", self._handle_update_emoji_event), - ("realm_user", self._handle_realm_user_event), - ] - ) + self.event_actions: Dict[str, Callable[[Event], None]] = { + "message": self._handle_message_event, + "update_message": self._handle_update_message_event, + "reaction": self._handle_reaction_event, + "submessage": self._handle_submessage_event, + "subscription": self._handle_subscription_event, + "typing": self._handle_typing_event, + "update_message_flags": self._handle_update_message_flags_event, + "update_global_notifications": self._handle_update_global_notifications_event, # noqa: E501 + "update_display_settings": self._handle_update_display_settings_event, + "user_settings": self._handle_user_settings_event, + "realm_emoji": self._handle_update_emoji_event, + "realm_user": self._handle_realm_user_event, + } self.initial_data: Dict[str, Any] = {} @@ -166,13 +170,17 @@ def __init__(self, controller: Any) -> None: # lose any updates while messages are being fetched. self._fetch_initial_data() - self._all_users_by_id: Dict[int, RealmUser] = {} - self._cross_realm_bots_by_id: Dict[int, RealmUser] = {} - self.server_version = self.initial_data["zulip_version"] - self.server_feature_level = self.initial_data.get("zulip_feature_level") + self.server_feature_level: int = self.initial_data.get("zulip_feature_level", 0) + + self._store_server_presence_intervals() - self.users = self.get_all_users() + self.user_dict: Dict[str, MinimalUserData] = {} + self.user_id_email_dict: Dict[int, str] = {} + self._all_users_by_id: Dict[int, RealmUser] = {} + self._cross_realm_bots_by_id: Dict[int, RealmUser] = {} + self.users: List[MinimalUserData] = [] + self._update_users_data_from_initial_data() self.stream_dict: Dict[int, Any] = {} self.muted_streams: Set[int] = set() @@ -184,8 +192,8 @@ def __init__(self, controller: Any) -> None: # NOTE: The date_created field of stream has been added in feature # level 30, server version 4. For consistency we add this field - # on server iterations even before this with value of None. - if self.server_feature_level is None or self.server_feature_level < 30: + # on earlier server iterations with the value of None. + if self.server_feature_level < 30: for stream in self.stream_dict.values(): stream["date_created"] = None @@ -198,7 +206,7 @@ def __init__(self, controller: Any) -> None: assert set(map(len, muted_topics)) in (set(), {2}, {3}) self._muted_topics: Dict[Tuple[str, str], Optional[int]] = { (stream_name, topic): ( - None if self.server_feature_level is None else date_muted[0] + None if self.server_feature_level == 0 else date_muted[0] ) for stream_name, topic, *date_muted in muted_topics } @@ -212,6 +220,7 @@ def __init__(self, controller: Any) -> None: self._draft: Optional[Composition] = None self._store_content_length_restrictions() + self._store_typing_duration_settings() self.active_emoji_data, self.all_emoji_names = self.generate_all_emoji_data( self.initial_data["realm_emoji"] @@ -253,7 +262,7 @@ def normalize_and_cache_message_retention_text(self) -> None: # sream_id in model.cached_retention_text. This will be displayed in the UI. self.cached_retention_text: Dict[int, str] = {} realm_message_retention_days = self.initial_data["realm_message_retention_days"] - if self.server_feature_level is None or self.server_feature_level < 17: + if self.server_feature_level < 17: for stream in self.stream_dict.values(): stream["message_retention_days"] = None @@ -270,12 +279,12 @@ def normalize_and_cache_message_retention_text(self) -> None: ) self.cached_retention_text[stream["stream_id"]] = message_retention_response - def get_focus_in_current_narrow(self) -> Union[int, Set[None]]: + def get_focus_in_current_narrow(self) -> Optional[int]: """ Returns the focus in the current narrow. - For no existing focus this returns {}, otherwise the message ID. + For no existing focus this returns None, otherwise the message ID. """ - return self.index["pointer"][repr(self.narrow)] + return self.index["pointer"].get(repr(self.narrow), None) def set_focus_in_current_narrow(self, focus_message: int) -> None: self.index["pointer"][repr(self.narrow)] = focus_message @@ -303,7 +312,7 @@ def set_narrow( frozenset(["stream"]): [["stream", stream]], frozenset(["stream", "topic"]): [["stream", stream], ["topic", topic]], frozenset(["pms"]): [["is", "private"]], - frozenset(["pm_with"]): [["pm_with", pm_with]], + frozenset(["pm_with"]): [["pm-with", pm_with]], frozenset(["starred"]): [["is", "starred"]], frozenset(["mentioned"]): [["is", "mentioned"]], } @@ -317,7 +326,7 @@ def set_narrow( if new_narrow != self.narrow: self.narrow = new_narrow - if pm_with is not None and new_narrow[0][0] == "pm_with": + if pm_with is not None and new_narrow[0][0] == "pm-with": users = pm_with.split(", ") self.recipients = frozenset( [self.user_dict[user]["user_id"] for user in users] + [self.user_id] @@ -363,7 +372,7 @@ def get_message_ids_in_current_narrow(self) -> Set[int]: ids = index["topic_msg_ids"][stream_id].get(topic, set()) elif narrow[0][1] == "private": ids = index["private_msg_ids"] - elif narrow[0][0] == "pm_with": + elif narrow[0][0] == "pm-with": recipients = self.recipients ids = index["private_msg_ids_by_user_ids"].get(recipients, set()) elif narrow[0][1] == "starred": @@ -403,7 +412,7 @@ def current_narrow_contains_message(self, message: Message) -> bool: ) # PM-with or ( - self.narrow[0][0] == "pm_with" + self.narrow[0][0] == "pm-with" and message["type"] == "private" and len(self.narrow) == 1 and self.recipients @@ -434,28 +443,22 @@ def _start_presence_updates(self) -> None: response = self._notify_server_of_presence() if response["result"] == "success": self.initial_data["presences"] = response["presences"] - self.users = self.get_all_users() + self._update_users_data_from_initial_data() if hasattr(self.controller, "view"): view = self.controller.view view.users_view.update_user_list(user_list=self.users) view.middle_column.update_message_list_status_markers() - time.sleep(60) + time.sleep(self.server_presence_ping_interval_secs) @asynch def toggle_message_reaction( self, message: Message, reaction_to_toggle: str ) -> None: - # Check if reaction_to_toggle is a valid original/alias assert reaction_to_toggle in self.all_emoji_names for emoji_name, emoji_data in self.active_emoji_data.items(): - if ( - reaction_to_toggle == emoji_name - or reaction_to_toggle in emoji_data["aliases"] - ): - # Found the emoji to toggle. Store its code/type and dont check further - emoji_code = emoji_data["code"] - emoji_type = emoji_data["type"] + if reaction_to_toggle in (emoji_name, *emoji_data["aliases"]): + emoji_code, emoji_type = emoji_data["code"], emoji_data["type"] break reaction_to_toggle_spec = dict( @@ -477,18 +480,22 @@ def has_user_reacted_to_message(self, message: Message, *, emoji_code: str) -> b for reaction in message["reactions"]: if reaction["emoji_code"] != emoji_code: continue - # The reaction.user_id field was added in Zulip v3.0, ZFL 2 so we need to - # check both the reaction.user.{user_id/id} fields too for pre v3 support. - user = reaction.get("user", {}) - has_user_reacted = ( - user.get("user_id", None) == self.user_id - or user.get("id", None) == self.user_id - or reaction.get("user_id", None) == self.user_id - ) - if has_user_reacted: + user_id = self.get_user_id_from_reaction(reaction) + if user_id == self.user_id: return True return False + def get_user_id_from_reaction( + self, reaction: Union[Dict[str, Any], ReactionEvent] + ) -> int: + # The reaction.user_id field was added in Zulip v3.0, ZFL 2 so we need to + # check both the reaction.user.{user_id/id} fields too for pre v3 support. + user = reaction.get("user", {}) + assert isinstance(user, dict) + user_id = user.get("id") or user.get("user_id") or reaction.get("user_id") + assert isinstance(user_id, int) + return user_id + def session_draft_message(self) -> Optional[Composition]: return deepcopy(self._draft) @@ -498,11 +505,11 @@ def save_draft(self, draft: Composition) -> None: @asynch def toggle_message_star_status(self, message: Message) -> None: - base_request = dict(flag="starred", messages=[message["id"]]) + messages = [message["id"]] if "starred" in message["flags"]: - request = dict(base_request, op="remove") + request = MessagesFlagChange(messages=messages, flag="starred", op="remove") else: - request = dict(base_request, op="add") + request = MessagesFlagChange(messages=messages, flag="starred", op="add") response = self.client.update_message_flags(request) display_error_if_present(response, self.controller) @@ -511,22 +518,18 @@ def mark_message_ids_as_read(self, id_list: List[int]) -> None: if not id_list: return response = self.client.update_message_flags( - { - "messages": id_list, - "flag": "read", - "op": "add", - } + MessagesFlagChange(messages=id_list, flag="read", op="add") ) display_error_if_present(response, self.controller) @asynch def send_typing_status_by_user_ids( - self, recipient_user_ids: List[int], *, status: Literal["start", "stop"] + self, recipient_user_ids: List[int], *, status: TypingStatusChange ) -> None: if not self.user_settings()["send_private_typing_notifications"]: return if recipient_user_ids: - request = {"to": recipient_user_ids, "op": status} + request: DirectTypingNotification = {"to": recipient_user_ids, "op": status} response = self.client.set_typing_status(request) display_error_if_present(response, self.controller) else: @@ -538,6 +541,7 @@ def send_private_message(self, recipients: List[int], content: str) -> bool: type="private", to=recipients, content=content, + read_by_sender=True, ) response = self.client.send_message(composition) display_error_if_present(response, self.controller) @@ -554,6 +558,7 @@ def send_stream_message(self, stream: str, topic: str, content: str) -> bool: to=stream, subject=topic, content=content, + read_by_sender=True, ) response = self.client.send_message(composition) display_error_if_present(response, self.controller) @@ -588,7 +593,7 @@ def update_stream_message( if content is not None: request["content"] = content - if self.server_feature_level is not None and self.server_feature_level >= 9: + if self.server_feature_level >= 9: request["send_notification_to_old_thread"] = notify_old request["send_notification_to_new_thread"] = notify_new @@ -733,7 +738,7 @@ def generate_all_emoji_data( all_emoji_names.append(emoji_name) all_emoji_names.extend(emoji_data["aliases"]) all_emoji_names = sorted(all_emoji_names) - active_emoji_data = OrderedDict(sorted(all_emoji_data.items())) + active_emoji_data = dict(sorted(all_emoji_data.items())) return active_emoji_data, all_emoji_names def get_messages( @@ -793,6 +798,37 @@ def _store_content_length_restrictions(self) -> None: "max_message_length", MAX_MESSAGE_LENGTH ) + def _store_typing_duration_settings(self) -> None: + """ + Store typing duration fields in model. + In ZFL 204, these values were made server-configurable. + Uses default values if not received from server. + """ + self.typing_started_wait_period = self.initial_data.get( + "server_typing_started_wait_period_milliseconds", + TYPING_STARTED_WAIT_PERIOD, + ) + self.typing_stopped_wait_period = self.initial_data.get( + "server_typing_stopped_wait_period_milliseconds", + TYPING_STOPPED_WAIT_PERIOD, + ) + self.typing_started_expiry_period = self.initial_data.get( + "server_typing_started_expiry_period_milliseconds", + TYPING_STARTED_EXPIRY_PERIOD, + ) + + def _store_server_presence_intervals(self) -> None: + """ + In ZFL 164, these values were added to the register response. + Uses default values if not received. + """ + self.server_presence_offline_threshold_secs = self.initial_data.get( + "server_presence_offline_threshold_seconds", PRESENCE_OFFLINE_THRESHOLD_SECS + ) + self.server_presence_ping_interval_secs = self.initial_data.get( + "server_presence_ping_interval_seconds", PRESENCE_PING_INTERVAL_SECS + ) + @staticmethod def modernize_message_response(message: Message) -> Message: """ @@ -870,6 +906,32 @@ def topics_in_stream(self, stream_id: int) -> List[str]: return list(self.index["topics"][stream_id]) + def _fetch_stream_email_from_endpoint(self, stream_id: int) -> Optional[str]: + """ + Endpoint added in Zulip 7.5 (ZFL 226) + """ + url = f"/streams/{stream_id}/email_address" + + response = self.client.call_endpoint(url, method="GET") + + if response.get("result") == "success": + email_address = response.get("email", "") + return str(email_address) + return None + + def get_stream_email_address(self, stream_id: int) -> Optional[str]: + """ + Returns the stream email address, or None if it is unavailable. + Beyond Zulip 7.5 / ZFL226, this requires a call to the server. + """ + if stream_id not in self.stream_dict: + raise RuntimeError("Invalid stream id.") + stream = self.stream_dict[stream_id] + stream_email = stream.get("email_address", None) + if stream_email is None: + stream_email = self._fetch_stream_email_from_endpoint(stream_id) + return stream_email + @staticmethod def exception_safe_result(future: "Future[str]") -> str: try: @@ -888,26 +950,94 @@ def is_muted_topic(self, stream_id: int, topic: str) -> bool: topic_to_search = (stream_name, topic) return topic_to_search in self._muted_topics - def get_next_unread_topic(self) -> Optional[Tuple[int, str]]: - unread_topics = sorted(self.unread_counts["unread_topics"].keys()) + def stream_topic_from_message_id( + self, message_id: int + ) -> Optional[Tuple[int, str]]: + """ + Returns the stream and topic of a message of a given message id. + If the message is not a stream message or if it is not present in the index, + None is returned. + """ + message = self.index["messages"].get(message_id, None) + if message is not None and message["type"] == "stream": + stream_id = message["stream_id"] + topic = message["subject"] + return (stream_id, topic) + return None + + def next_unread_topic_from_message_id( + self, current_message: Optional[int] + ) -> Optional[Tuple[int, str]]: + if current_message: + current_topic = self.stream_topic_from_message_id(current_message) + else: + current_topic = ( + self.stream_id_from_name(self.narrow[0][1]), + self.narrow[1][1], + ) + left_panel_stream_list = [ + stream["id"] for stream in (self.pinned_streams + self.unpinned_streams) + ] + unread_topics = sort_unread_topics( + self.unread_counts["unread_topics"], + left_panel_stream_list, + ) next_topic = False - if self._last_unread_topic not in unread_topics: + stream_start: Optional[Tuple[int, str]] = None + if current_topic is None: next_topic = True + elif current_topic not in unread_topics: + # insert current_topic in list of unread_topics for the case where + # current_topic is not in unread_topics, and the next unmuted topic + # is to be returned. This does not modify the original unread topics + # data, and is just used to compute the next unmuted topic to be returned. + unread_topics = sort_unread_topics( + {**self.unread_counts["unread_topics"], current_topic: 0}, + left_panel_stream_list, + ) # loop over unread_topics list twice for the case that last_unread_topic was # the last valid unread_topic in unread_topics list. for unread_topic in unread_topics * 2: stream_id, topic_name = unread_topic - if ( - not self.is_muted_topic(stream_id, topic_name) - and not self.is_muted_stream(stream_id) - and next_topic - ): - self._last_unread_topic = unread_topic - return unread_topic - if unread_topic == self._last_unread_topic: + if not self.is_muted_topic( + stream_id, topic_name + ) and not self.is_muted_stream(stream_id): + if next_topic: + if unread_topic == current_topic: + return None + if ( + current_topic is not None + and unread_topic[0] != current_topic[0] + and stream_start != current_topic + ): + return stream_start + return unread_topic + + if ( + stream_start is None + and current_topic is not None + and unread_topic[0] == current_topic[0] + ): + stream_start = unread_topic + if unread_topic == current_topic: next_topic = True return None + def get_next_unread_pm(self) -> Optional[int]: + pms = list(self.unread_counts["unread_pms"].keys()) + next_pm = False + for pm in pms: + if next_pm is True: + self.last_unread_pm = pm + return pm + if pm == self.last_unread_pm: + next_pm = True + if len(pms) > 0: + pm = pms[0] + self.last_unread_pm = pm + return pm + return None + def _fetch_initial_data(self) -> None: # Thread Processes to reduce start time. # NOTE: Exceptions do not work well with threads @@ -965,6 +1095,56 @@ def get_other_subscribers_in_stream( if sub != self.user_id ] + def _clean_and_order_custom_profile_data( + self, custom_profile_data: Dict[str, CustomFieldValue] + ) -> List[CustomProfileData]: + # Get custom profile fields + profile_fields = { + str(field["id"]): field + for field in self.initial_data["custom_profile_fields"] + } + + cleaned_profile_data = [] + for field_id in custom_profile_data: + field = profile_fields[field_id] + raw_value = custom_profile_data[field_id]["value"] + + if field["type"] == 3: # List of options + field_options = json.loads(field["field_data"]) + field_value = field_options[raw_value]["text"] + + elif field["type"] == 6: # Person picker + field_value = list( + map( + int, + raw_value.strip("][").split(","), + ) + ) + + elif field["type"] == 7: # External Account + field_options = json.loads(field["field_data"]) + field_subtype = field_options["subtype"] + if field_subtype == "github": + url_pattern = "https://github.com/%(username)s" + elif field_subtype == "twitter": + url_pattern = "https://twitter.com/%(username)s" + else: + url_pattern = field_options["url_pattern"] + field_value = url_pattern % {"username": raw_value} + else: + field_value = raw_value + + data: CustomProfileData = { + "label": field["name"], + "value": field_value, + "type": field["type"], + "order": field["order"], + } + cleaned_profile_data.append(data) + + cleaned_profile_data.sort(key=lambda field: field["order"]) + return cleaned_profile_data + def get_user_info(self, user_id: int) -> Optional[TidiedUserInfo]: api_user_data: Optional[RealmUser] = self._all_users_by_id.get(user_id, None) @@ -984,6 +1164,10 @@ def get_user_info(self, user_id: int) -> Optional[TidiedUserInfo]: else: user_role = raw_user_role + custom_profile_data = api_user_data.get("profile_data", {}) + cleaned_custom_profile_data = self._clean_and_order_custom_profile_data( + custom_profile_data + ) # TODO: Add custom fields later as an enhancement user_info: TidiedUserInfo = dict( full_name=api_user_data.get("full_name", "(No name)"), @@ -992,12 +1176,13 @@ def get_user_info(self, user_id: int) -> Optional[TidiedUserInfo]: timezone=api_user_data.get("timezone", ""), is_bot=api_user_data.get("is_bot", False), role=user_role, + custom_profile_data=cleaned_custom_profile_data, bot_type=api_user_data.get("bot_type", None), bot_owner_name="", # Can be non-empty only if is_bot == True last_active="", ) - bot_owner: Optional[Union[RealmUser, Dict[str, Any]]] = None + bot_owner: Optional[Union[RealmUser, MinimalUserData]] = None if api_user_data.get("bot_owner_id", None): bot_owner = self._all_users_by_id.get(api_user_data["bot_owner_id"], None) @@ -1019,18 +1204,18 @@ def get_user_info(self, user_id: int) -> Optional[TidiedUserInfo]: return user_info - def get_all_users(self) -> List[Dict[str, Any]]: + def _update_users_data_from_initial_data(self) -> None: # Dict which stores the active/idle status of users (by email) presences = self.initial_data["presences"] # Construct a dict of each user in the realm to look up by email # and a user-id to email mapping - self.user_dict: Dict[str, Dict[str, Any]] = dict() - self.user_id_email_dict: Dict[int, str] = dict() + self.user_dict = dict() + self.user_id_email_dict = dict() for user in self.initial_data["realm_users"]: if self.user_id == user["user_id"]: self._all_users_by_id[self.user_id] = user - current_user = { + current_user: MinimalUserData = { "full_name": user["full_name"], "email": user["email"], "user_id": user["user_id"], @@ -1038,7 +1223,12 @@ def get_all_users(self) -> List[Dict[str, Any]]: } continue email = user["email"] - if email in presences: # presences currently subset of all users + + status: UserStatus + if user["is_bot"]: + # Bot has no dynamic status, so avoid presence lookup + status = "bot" + elif email in presences: # presences currently subset of all users """ * Aggregate our information on a user's presence across their * clients. @@ -1053,21 +1243,23 @@ def get_all_users(self) -> List[Dict[str, Any]]: * * Out of the ClientPresence objects found in `presence`, we * consider only those with a timestamp newer than - * OFFLINE_THRESHOLD_SECS; then of + * self.server_presence_offline_threshold_secs; then of * those, return the one that has the greatest UserStatus, where * `active` > `idle` > `offline`. * * If there are several ClientPresence objects with the greatest * UserStatus, an arbitrary one is chosen. """ - aggregate_status = "offline" + aggregate_status: UserStatus = "offline" for client in presences[email].items(): client_name = client[0] status = client[1]["status"] timestamp = client[1]["timestamp"] if client_name == "aggregated": continue - elif (time.time() - timestamp) < OFFLINE_THRESHOLD_SECS: + elif ( + time.time() - timestamp + ) < self.server_presence_offline_threshold_secs: if status == "active": aggregate_status = "active" if status == "idle" and aggregate_status != "active": @@ -1089,6 +1281,7 @@ def get_all_users(self) -> List[Dict[str, Any]]: "user_id": user["user_id"], "status": status, } + self._all_users_by_id[user["user_id"]] = user self.user_id_email_dict[user["user_id"]] = email @@ -1099,45 +1292,37 @@ def get_all_users(self) -> List[Dict[str, Any]]: "full_name": bot["full_name"], "email": email, "user_id": bot["user_id"], - "status": "inactive", + "status": "bot", } self._cross_realm_bots_by_id[bot["user_id"]] = bot self._all_users_by_id[bot["user_id"]] = bot self.user_id_email_dict[bot["user_id"]] = email - # Generate filtered lists for active & idle users - active = [ - properties - for properties in self.user_dict.values() - if properties["status"] == "active" - ] - idle = [ - properties - for properties in self.user_dict.values() - if properties["status"] == "idle" - ] - offline = [ - properties - for properties in self.user_dict.values() - if properties["status"] == "offline" - ] - inactive = [ - properties - for properties in self.user_dict.values() - if properties["status"] == "inactive" - ] + # Generate filtered lists for each status + ordered_statuses = list(STATE_ICON.keys()) + presences_by_status = { + status: sorted( + [ + properties + for properties in self.user_dict.values() + if properties["status"] == status + ], + key=lambda user: user["full_name"].casefold(), + ) + for status in ordered_statuses + } + user_list = list( + itertools.chain.from_iterable( + presences_by_status[status] for status in ordered_statuses + ) + ) + user_list.insert(0, current_user) # Add current user to the top of the list - # Construct user_list from sorted components of each list - user_list = sorted(active, key=lambda u: u["full_name"].casefold()) - user_list += sorted(idle, key=lambda u: u["full_name"].casefold()) - user_list += sorted(offline, key=lambda u: u["full_name"].casefold()) - user_list += sorted(inactive, key=lambda u: u["full_name"].casefold()) - # Add current user to the top of the list - user_list.insert(0, current_user) + # NOTE: Do this after generating user_list to avoid current_user duplication self.user_dict[current_user["email"]] = current_user self.user_id_email_dict[self.user_id] = current_user["email"] - return user_list + self.users = user_list def user_name_from_id(self, user_id: int) -> str: """ @@ -1217,12 +1402,12 @@ def _group_info_from_realm_user_groups( def toggle_stream_muted_status(self, stream_id: int) -> None: request = [ - { - "stream_id": stream_id, - "property": "is_muted", - "value": not self.is_muted_stream(stream_id) + SubscriptionSettingChange( + stream_id=stream_id, + property="is_muted", + value=not self.is_muted_stream(stream_id) # True for muting and False for unmuting. - } + ) ] response = self.client.update_subscription_settings(request) display_error_if_present(response, self.controller) @@ -1248,11 +1433,11 @@ def is_pinned_stream(self, stream_id: int) -> bool: def toggle_stream_pinned_status(self, stream_id: int) -> bool: request = [ - { - "stream_id": stream_id, - "property": "pin_to_top", - "value": not self.is_pinned_stream(stream_id), - } + SubscriptionSettingChange( + stream_id=stream_id, + property="pin_to_top", + value=not self.is_pinned_stream(stream_id), + ) ] response = self.client.update_subscription_settings(request) return response["result"] == "success" @@ -1265,11 +1450,11 @@ def is_visual_notifications_enabled(self, stream_id: int) -> bool: def toggle_stream_visual_notifications(self, stream_id: int) -> None: request = [ - { - "stream_id": stream_id, - "property": "desktop_notifications", - "value": not self.is_visual_notifications_enabled(stream_id), - } + SubscriptionSettingChange( + stream_id=stream_id, + property="desktop_notifications", + value=not self.is_visual_notifications_enabled(stream_id), + ) ] response = self.client.update_subscription_settings(request) display_error_if_present(response, self.controller) @@ -1387,7 +1572,7 @@ def _handle_typing_event(self, event: Event) -> None: # and the person typing isn't the user themselves if ( len(narrow) == 1 - and narrow[0][0] == "pm_with" + and narrow[0][0] == "pm-with" and sender_email in narrow[0][1].split(",") and sender_id != self.user_id ): @@ -1529,9 +1714,9 @@ def _handle_message_event(self, event: Event) -> None: "Press '{}' to close this window." ) notice = notice_template.format( - failed_command, primary_key_for_command("GO_BACK") + failed_command, primary_display_key_for_command("EXIT_POPUP") ) - self.controller.popup_with_message(notice, width=50) + self.controller.show_popup_with_message(notice, width=50) self.controller.update_screen() self._notified_user_of_notification_failure = True @@ -1584,6 +1769,7 @@ def _handle_update_message_event(self, event: Event) -> None: Handle updated (edited) messages (changed content/subject) """ assert event["type"] == "update_message" + # Update edited message status from single message id # NOTE: If all messages in topic have topic edited, # they are not all marked as edited, as per server optimization @@ -1595,7 +1781,9 @@ def _handle_update_message_event(self, event: Event) -> None: # Update the rendered content, if the message is indexed if "rendered_content" in event and indexed_message: - indexed_message["content"] = event["rendered_content"] + content_event = cast(UpdateMessageContentEvent, event) + indexed_message["content"] = content_event["rendered_content"] + indexed_message["is_me_message"] = content_event["is_me_message"] self.index["messages"][message_id] = indexed_message self._update_rendered_view(message_id) @@ -1604,14 +1792,15 @@ def _handle_update_message_event(self, event: Event) -> None: # * 'subject' is not present in update event if # the event didn't have a 'subject' update. if "subject" in event: - new_subject = event["subject"] - stream_id = event["stream_id"] - old_subject = event["orig_subject"] + location_event = cast(UpdateMessagesLocationEvent, event) + new_subject = location_event["subject"] + stream_id = location_event["stream_id"] + old_subject = location_event["orig_subject"] msg_ids_by_topic = self.index["topic_msg_ids"][stream_id] # Remove each message_id from the old topic's `topic_msg_ids` set # if it exists, and update & re-render the message if it is indexed. - for msg_id in event["message_ids"]: + for msg_id in location_event["message_ids"]: # Ensure that the new topic is not the same as the old one # (no-op topic edit). if new_subject != old_subject: @@ -1648,35 +1837,72 @@ def _handle_reaction_event(self, event: Event) -> None: """ assert event["type"] == "reaction" message_id = event["message_id"] - # If the message is indexed if message_id in self.index["messages"]: message = self.index["messages"][message_id] if event["op"] == "add": - message["reactions"].append( - { - "user": event["user"], - "reaction_type": event["reaction_type"], - "emoji_code": event["emoji_code"], - "emoji_name": event["emoji_name"], + reactions_entry = { + key: event.get(key) + for key in [ + "user", + "reaction_type", + "emoji_code", + "emoji_name", + "user_id", + ] + } + + # Convert from reaction event schema to message reactions schema + if isinstance(event.get("user"), dict) and isinstance( + reactions_entry["user"], dict + ): + reactions_entry["user"] = { + **event["user"], + "id": event["user"].get("user_id"), } - ) + reactions_entry["user"].pop("user_id", None) + + message["reactions"].append(reactions_entry) else: - emoji_code = event["emoji_code"] for reaction in message["reactions"]: - # Since Who reacted is not displayed, - # remove the first one encountered - if reaction["emoji_code"] == emoji_code: + reaction_user_id = self.get_user_id_from_reaction(reaction) + event_user_id = self.get_user_id_from_reaction(event) + if ( + reaction["emoji_code"] == event["emoji_code"] + and reaction_user_id == event_user_id + ): message["reactions"].remove(reaction) + break self.index["messages"][message_id] = message self._update_rendered_view(message_id) + def _handle_submessage_event(self, event: Event) -> None: + """ + Handle change to submessages on a message (todo, poll etc.) + """ + assert event["type"] == "submessage" + message_id = event["message_id"] + if message_id in self.index["messages"]: + message = self.index["messages"][message_id] + message["submessages"].append( + { + "type": event["type"], + "msg_type": event["msg_type"], + "message_id": event["message_id"], + "submessage_id": event["submessage_id"], + "sender_id": event["sender_id"], + "content": event["content"], + } + ) + self.index["messages"][message_id] = message + self._update_rendered_view(message_id) + def _handle_update_message_flags_event(self, event: Event) -> None: """ Handle change to message flags (eg. starred, read) """ assert event["type"] == "update_message_flags" - if self.server_feature_level is None or self.server_feature_level < 32: + if self.server_feature_level < 32: operation = event["operation"] else: operation = event["op"] @@ -1819,8 +2045,9 @@ def _handle_user_settings_event(self, event: Event) -> None: """ assert event["type"] == "user_settings" # We only expect these to be "update" event operations + assert event["op"] == "update" # Update the setting (property) to the value, but only if already initialized - if event["op"] == "update" and event["property"] in self._user_settings: + if event["property"] in self._user_settings: setting = event["property"] self._user_settings[setting] = event["value"] @@ -1859,6 +2086,21 @@ def _handle_realm_user_event(self, event: Event) -> None: # realm_users has 'email' attribute and not 'new_email' if "new_email" in updated_details: realm_user["email"] = updated_details["new_email"] + + elif "custom_profile_field" in updated_details: + profile_field_data = updated_details["custom_profile_field"] + profile_field_id = str(profile_field_data["id"]) + + if profile_field_data["value"] is None: + # Ignore if field does not exist + realm_user["profile_data"].pop(profile_field_id, None) + else: + updated_data = { + key: value + for key, value in profile_field_data.items() + if key != "id" + } + realm_user["profile_data"][profile_field_id] = updated_data else: realm_user.update(updated_details) break diff --git a/zulipterminal/platform_code.py b/zulipterminal/platform_code.py index 89e25bedc9..2741e8ec38 100644 --- a/zulipterminal/platform_code.py +++ b/zulipterminal/platform_code.py @@ -4,10 +4,37 @@ import platform import subprocess +from typing import Tuple from typing_extensions import Literal +# PYTHON DETECTION +def detected_python() -> Tuple[str, str, str]: + return ( + platform.python_version(), + platform.python_implementation(), + platform.python_branch(), + ) + + +def detected_python_in_full() -> str: + version, implementation, branch = detected_python() + branch_text = f"[{branch}]" if branch else "" + return f"{version} ({implementation}) {branch_text}" + + +def detected_python_short() -> str: + """Concise output for comparison in CI (CPython implied in version)""" + version, implementation, _ = detected_python() + short_version = version[: version.rfind(".")] + if implementation == "CPython": + return short_version + if implementation == "PyPy": + return f"pypy-{short_version}" + raise NotImplementedError + + # PLATFORM DETECTION SupportedPlatforms = Literal["Linux", "MacOS", "WSL"] AllPlatforms = Literal[SupportedPlatforms, "unsupported"] diff --git a/zulipterminal/scripts/render_symbols.py b/zulipterminal/scripts/render_symbols.py index e0e80b3324..f4a3850da8 100755 --- a/zulipterminal/scripts/render_symbols.py +++ b/zulipterminal/scripts/render_symbols.py @@ -13,11 +13,24 @@ ("footer", "black", "white"), ] -symbol_dict = { +initial_symbol_dict = { name: symbol for name, symbol in vars(symbols).items() if not name.startswith("__") and not name.endswith("__") } +nested_dict = { + f"{name}__{subname}": subsymbol + for name, symbol in initial_symbol_dict.items() + if isinstance(symbol, dict) + for subname, subsymbol in symbol.items() +} +symbol_dict = { + name: symbol + for name, symbol in initial_symbol_dict.items() + if not isinstance(symbol, dict) +} +symbol_dict.update(nested_dict) + max_symbol_name_length = max([len(name) for name in symbol_dict]) symbol_names_list = [urwid.Text(name, align="center") for name in symbol_dict] diff --git a/zulipterminal/themes/colors_gruvbox.py b/zulipterminal/themes/colors_gruvbox.py index 0bf7266826..79d2e2fbdc 100644 --- a/zulipterminal/themes/colors_gruvbox.py +++ b/zulipterminal/themes/colors_gruvbox.py @@ -21,7 +21,6 @@ class GruvBoxColor(Enum): # color = 16code 256code 24code - DEFAULT = 'default default default' # Only or primarily dark mode - grayscales # - generally background diff --git a/zulipterminal/themes/gruvbox_dark.py b/zulipterminal/themes/gruvbox_dark.py index 5e0c232521..4492b75510 100644 --- a/zulipterminal/themes/gruvbox_dark.py +++ b/zulipterminal/themes/gruvbox_dark.py @@ -11,6 +11,7 @@ from pygments.styles.solarized import SolarizedDarkStyle +from zulipterminal.config.color import Background from zulipterminal.themes.colors_gruvbox import DefaultBoldColor as Color @@ -18,57 +19,58 @@ STYLES = { # style_name : foreground background - None : (Color.LIGHT2, Color.DARK0_HARD), + None : (Color.LIGHT2, Background.COLOR), 'selected' : (Color.DARK0_HARD, Color.NEUTRAL_BLUE), 'msg_selected' : (Color.DARK0_HARD, Color.NEUTRAL_BLUE), 'header' : (Color.NEUTRAL_BLUE, Color.BRIGHT_BLUE), 'general_narrow' : (Color.DARK0_HARD, Color.BRIGHT_BLUE), - 'general_bar' : (Color.LIGHT2, Color.DARK0_HARD), - 'msg_sender' : (Color.NEUTRAL_YELLOW__BOLD, Color.DARK0_HARD), - 'unread' : (Color.NEUTRAL_PURPLE, Color.DARK0_HARD), - 'user_active' : (Color.BRIGHT_GREEN, Color.DARK0_HARD), - 'user_idle' : (Color.NEUTRAL_YELLOW, Color.DARK0_HARD), - 'user_offline' : (Color.LIGHT2, Color.DARK0_HARD), - 'user_inactive' : (Color.LIGHT2, Color.DARK0_HARD), - 'title' : (Color.LIGHT2__BOLD, Color.DARK0_HARD), - 'column_title' : (Color.LIGHT2__BOLD, Color.DARK0_HARD), - 'time' : (Color.BRIGHT_BLUE, Color.DARK0_HARD), + 'general_bar' : (Color.LIGHT2, Background.COLOR), + 'msg_sender' : (Color.NEUTRAL_YELLOW__BOLD, Background.COLOR), + 'unread' : (Color.NEUTRAL_PURPLE, Background.COLOR), + 'user_active' : (Color.BRIGHT_GREEN, Background.COLOR), + 'user_idle' : (Color.NEUTRAL_YELLOW, Background.COLOR), + 'user_offline' : (Color.LIGHT2, Background.COLOR), + 'user_inactive' : (Color.LIGHT2, Background.COLOR), + 'user_bot' : (Color.LIGHT2, Background.COLOR), + 'title' : (Color.LIGHT2__BOLD, Background.COLOR), + 'column_title' : (Color.LIGHT2__BOLD, Background.COLOR), + 'time' : (Color.BRIGHT_BLUE, Background.COLOR), 'bar' : (Color.LIGHT2, Color.GRAY_244), - 'msg_emoji' : (Color.NEUTRAL_PURPLE, Color.DARK0_HARD), - 'reaction' : (Color.NEUTRAL_PURPLE__BOLD, Color.DARK0_HARD), + 'msg_emoji' : (Color.NEUTRAL_PURPLE, Background.COLOR), + 'reaction' : (Color.NEUTRAL_PURPLE__BOLD, Background.COLOR), 'reaction_mine' : (Color.DARK0_HARD, Color.NEUTRAL_PURPLE), 'msg_heading' : (Color.DARK0_HARD__BOLD, Color.BRIGHT_GREEN), 'msg_math' : (Color.DARK0_HARD, Color.GRAY_244), - 'msg_mention' : (Color.BRIGHT_RED__BOLD, Color.DARK0_HARD), - 'msg_link' : (Color.BRIGHT_BLUE, Color.DARK0_HARD), - 'msg_link_index' : (Color.BRIGHT_BLUE__BOLD, Color.DARK0_HARD), - 'msg_quote' : (Color.NEUTRAL_YELLOW, Color.DARK0_HARD), - 'msg_bold' : (Color.LIGHT2__BOLD, Color.DARK0_HARD), + 'msg_mention' : (Color.BRIGHT_RED__BOLD, Background.COLOR), + 'msg_link' : (Color.BRIGHT_BLUE, Background.COLOR), + 'msg_link_index' : (Color.BRIGHT_BLUE__BOLD, Background.COLOR), + 'msg_quote' : (Color.NEUTRAL_YELLOW, Background.COLOR), + 'msg_bold' : (Color.LIGHT2__BOLD, Background.COLOR), 'msg_time' : (Color.DARK0_HARD, Color.LIGHT2), 'footer' : (Color.DARK0_HARD, Color.LIGHT4), - 'footer_contrast' : (Color.LIGHT2, Color.DARK0_HARD), - 'starred' : (Color.BRIGHT_RED__BOLD, Color.DARK0_HARD), - 'unread_count' : (Color.NEUTRAL_YELLOW, Color.DARK0_HARD), - 'starred_count' : (Color.LIGHT4, Color.DARK0_HARD), - 'table_head' : (Color.LIGHT2__BOLD, Color.DARK0_HARD), + 'footer_contrast' : (Color.LIGHT2, Background.COLOR), + 'starred' : (Color.BRIGHT_RED__BOLD, Background.COLOR), + 'unread_count' : (Color.NEUTRAL_YELLOW, Background.COLOR), + 'starred_count' : (Color.LIGHT4, Background.COLOR), + 'table_head' : (Color.LIGHT2__BOLD, Background.COLOR), 'filter_results' : (Color.DARK0_HARD, Color.BRIGHT_GREEN), 'edit_topic' : (Color.DARK0_HARD, Color.GRAY_244), 'edit_tag' : (Color.DARK0_HARD, Color.GRAY_244), - 'edit_author' : (Color.NEUTRAL_YELLOW, Color.DARK0_HARD), - 'edit_time' : (Color.BRIGHT_BLUE, Color.DARK0_HARD), - 'current_user' : (Color.LIGHT2, Color.DARK0_HARD), - 'muted' : (Color.BRIGHT_BLUE, Color.DARK0_HARD), - 'popup_border' : (Color.LIGHT2, Color.DARK0_HARD), - 'popup_category' : (Color.BRIGHT_BLUE__BOLD, Color.DARK0_HARD), + 'edit_author' : (Color.NEUTRAL_YELLOW, Background.COLOR), + 'edit_time' : (Color.BRIGHT_BLUE, Background.COLOR), + 'current_user' : (Color.LIGHT2, Background.COLOR), + 'muted' : (Color.BRIGHT_BLUE, Background.COLOR), + 'popup_border' : (Color.LIGHT2, Background.COLOR), + 'popup_category' : (Color.BRIGHT_BLUE__BOLD, Background.COLOR), 'popup_contrast' : (Color.DARK0_HARD, Color.GRAY_244), - 'popup_important' : (Color.BRIGHT_RED__BOLD, Color.DARK0_HARD), - 'widget_disabled' : (Color.GRAY_244, Color.DARK0_HARD), + 'popup_important' : (Color.BRIGHT_RED__BOLD, Background.COLOR), + 'widget_disabled' : (Color.GRAY_244, Background.COLOR), 'area:help' : (Color.DARK0_HARD, Color.BRIGHT_GREEN), 'area:msg' : (Color.DARK0_HARD, Color.NEUTRAL_PURPLE), 'area:stream' : (Color.DARK0_HARD, Color.BRIGHT_BLUE), 'area:error' : (Color.DARK0_HARD, Color.BRIGHT_RED), 'area:user' : (Color.DARK0_HARD, Color.BRIGHT_YELLOW), - 'search_error' : (Color.BRIGHT_RED, Color.DARK0_HARD), + 'search_error' : (Color.BRIGHT_RED, Background.COLOR), 'task:success' : (Color.DARK0_HARD, Color.BRIGHT_GREEN), 'task:error' : (Color.DARK0_HARD, Color.BRIGHT_RED), 'task:warning' : (Color.DARK0_HARD, Color.NEUTRAL_PURPLE), @@ -76,6 +78,7 @@ } META = { + 'background': Color.DARK0_HARD, 'pygments': { 'styles' : SolarizedDarkStyle().styles, 'background': 'h236', diff --git a/zulipterminal/themes/gruvbox_light.py b/zulipterminal/themes/gruvbox_light.py index 7c536de97a..e2c09ae0c8 100644 --- a/zulipterminal/themes/gruvbox_light.py +++ b/zulipterminal/themes/gruvbox_light.py @@ -10,6 +10,7 @@ """ from pygments.styles.solarized import SolarizedLightStyle +from zulipterminal.config.color import Background from zulipterminal.themes.colors_gruvbox import DefaultBoldColor as Color @@ -17,57 +18,58 @@ STYLES = { # style_name : foreground background - None : (Color.DARK2, Color.LIGHT0_HARD), + None : (Color.DARK2, Background.COLOR), 'selected' : (Color.LIGHT0_HARD, Color.NEUTRAL_BLUE), 'msg_selected' : (Color.LIGHT0_HARD, Color.NEUTRAL_BLUE), 'header' : (Color.NEUTRAL_BLUE, Color.FADED_BLUE), 'general_narrow' : (Color.LIGHT0_HARD, Color.FADED_BLUE), - 'general_bar' : (Color.DARK2, Color.LIGHT0_HARD), - 'msg_sender' : (Color.NEUTRAL_YELLOW, Color.LIGHT0_HARD), - 'unread' : (Color.NEUTRAL_PURPLE, Color.LIGHT0_HARD), - 'user_active' : (Color.FADED_GREEN, Color.LIGHT0_HARD), - 'user_idle' : (Color.NEUTRAL_YELLOW, Color.LIGHT0_HARD), - 'user_offline' : (Color.DARK2, Color.LIGHT0_HARD), - 'user_inactive' : (Color.DARK2, Color.LIGHT0_HARD), - 'title' : (Color.DARK2__BOLD, Color.LIGHT0_HARD), - 'column_title' : (Color.DARK2__BOLD, Color.LIGHT0_HARD), - 'time' : (Color.FADED_BLUE, Color.LIGHT0_HARD), + 'general_bar' : (Color.DARK2, Background.COLOR), + 'msg_sender' : (Color.NEUTRAL_YELLOW, Background.COLOR), + 'unread' : (Color.NEUTRAL_PURPLE, Background.COLOR), + 'user_active' : (Color.FADED_GREEN, Background.COLOR), + 'user_idle' : (Color.NEUTRAL_YELLOW, Background.COLOR), + 'user_offline' : (Color.DARK2, Background.COLOR), + 'user_inactive' : (Color.DARK2, Background.COLOR), + 'user_bot' : (Color.DARK2, Background.COLOR), + 'title' : (Color.DARK2__BOLD, Background.COLOR), + 'column_title' : (Color.DARK2__BOLD, Background.COLOR), + 'time' : (Color.FADED_BLUE, Background.COLOR), 'bar' : (Color.DARK2, Color.GRAY_245), - 'msg_emoji' : (Color.NEUTRAL_PURPLE, Color.LIGHT0_HARD), - 'reaction' : (Color.NEUTRAL_PURPLE__BOLD, Color.LIGHT0_HARD), + 'msg_emoji' : (Color.NEUTRAL_PURPLE, Background.COLOR), + 'reaction' : (Color.NEUTRAL_PURPLE__BOLD, Background.COLOR), 'reaction_mine' : (Color.LIGHT0_HARD, Color.NEUTRAL_PURPLE), 'msg_heading' : (Color.LIGHT0_HARD__BOLD, Color.FADED_GREEN), 'msg_math' : (Color.LIGHT0_HARD, Color.GRAY_245), - 'msg_mention' : (Color.FADED_RED__BOLD, Color.LIGHT0_HARD), - 'msg_link' : (Color.FADED_BLUE, Color.LIGHT0_HARD), - 'msg_link_index' : (Color.FADED_BLUE__BOLD, Color.LIGHT0_HARD), - 'msg_quote' : (Color.NEUTRAL_YELLOW, Color.LIGHT0_HARD), - 'msg_bold' : (Color.DARK2__BOLD, Color.LIGHT0_HARD), + 'msg_mention' : (Color.FADED_RED__BOLD, Background.COLOR), + 'msg_link' : (Color.FADED_BLUE, Background.COLOR), + 'msg_link_index' : (Color.FADED_BLUE__BOLD, Background.COLOR), + 'msg_quote' : (Color.NEUTRAL_YELLOW, Background.COLOR), + 'msg_bold' : (Color.DARK2__BOLD, Background.COLOR), 'msg_time' : (Color.LIGHT0_HARD, Color.DARK2), 'footer' : (Color.LIGHT0_HARD, Color.DARK4), - 'footer_contrast' : (Color.DARK2, Color.LIGHT0_HARD), - 'starred' : (Color.FADED_RED__BOLD, Color.LIGHT0_HARD), - 'unread_count' : (Color.NEUTRAL_YELLOW, Color.LIGHT0_HARD), - 'starred_count' : (Color.DARK4, Color.LIGHT0_HARD), - 'table_head' : (Color.DARK2__BOLD, Color.LIGHT0_HARD), + 'footer_contrast' : (Color.DARK2, Background.COLOR), + 'starred' : (Color.FADED_RED__BOLD, Background.COLOR), + 'unread_count' : (Color.NEUTRAL_YELLOW, Background.COLOR), + 'starred_count' : (Color.DARK4, Background.COLOR), + 'table_head' : (Color.DARK2__BOLD, Background.COLOR), 'filter_results' : (Color.LIGHT0_HARD, Color.FADED_GREEN), 'edit_topic' : (Color.LIGHT0_HARD, Color.GRAY_245), 'edit_tag' : (Color.LIGHT0_HARD, Color.GRAY_245), - 'edit_author' : (Color.NEUTRAL_YELLOW, Color.LIGHT0_HARD), - 'edit_time' : (Color.FADED_BLUE, Color.LIGHT0_HARD), - 'current_user' : (Color.DARK2, Color.LIGHT0_HARD), - 'muted' : (Color.FADED_BLUE, Color.LIGHT0_HARD), - 'popup_border' : (Color.DARK2, Color.LIGHT0_HARD), - 'popup_category' : (Color.FADED_BLUE__BOLD, Color.LIGHT0_HARD), + 'edit_author' : (Color.NEUTRAL_YELLOW, Background.COLOR), + 'edit_time' : (Color.FADED_BLUE, Background.COLOR), + 'current_user' : (Color.DARK2, Background.COLOR), + 'muted' : (Color.FADED_BLUE, Background.COLOR), + 'popup_border' : (Color.DARK2, Background.COLOR), + 'popup_category' : (Color.FADED_BLUE__BOLD, Background.COLOR), 'popup_contrast' : (Color.LIGHT0_HARD, Color.GRAY_245), - 'popup_important' : (Color.FADED_RED__BOLD, Color.LIGHT0_HARD), - 'widget_disabled' : (Color.GRAY_245, Color.LIGHT0_HARD), + 'popup_important' : (Color.FADED_RED__BOLD, Background.COLOR), + 'widget_disabled' : (Color.GRAY_245, Background.COLOR), 'area:help' : (Color.LIGHT0_HARD, Color.FADED_GREEN), 'area:msg' : (Color.LIGHT0_HARD, Color.NEUTRAL_PURPLE), 'area:stream' : (Color.LIGHT0_HARD, Color.FADED_BLUE), 'area:error' : (Color.LIGHT0_HARD, Color.FADED_RED), 'area:user' : (Color.LIGHT0_HARD, Color.FADED_YELLOW), - 'search_error' : (Color.FADED_RED, Color.LIGHT0_HARD), + 'search_error' : (Color.FADED_RED, Background.COLOR), 'task:success' : (Color.LIGHT0_HARD, Color.FADED_GREEN), 'task:error' : (Color.LIGHT0_HARD, Color.FADED_RED), 'task:warning' : (Color.LIGHT0_HARD, Color.NEUTRAL_PURPLE), @@ -75,6 +77,7 @@ } META = { + 'background': Color.LIGHT0_HARD, 'pygments': { 'styles' : SolarizedLightStyle().styles, 'background': '#ffffcc', diff --git a/zulipterminal/themes/zt_blue.py b/zulipterminal/themes/zt_blue.py index d6fed8b0cc..0a43fe903c 100644 --- a/zulipterminal/themes/zt_blue.py +++ b/zulipterminal/themes/zt_blue.py @@ -6,34 +6,36 @@ """ from pygments.styles.zenburn import ZenburnStyle +from zulipterminal.config.color import Background from zulipterminal.config.color import DefaultBoldColor as Color # fmt: off STYLES = { # style_name : foreground background - None : (Color.BLACK, Color.LIGHT_BLUE), + None : (Color.BLACK, Background.COLOR), 'selected' : (Color.BLACK, Color.LIGHT_GRAY), 'msg_selected' : (Color.BLACK, Color.LIGHT_GRAY), 'header' : (Color.BLACK, Color.DARK_BLUE), 'general_narrow' : (Color.WHITE, Color.DARK_BLUE), - 'general_bar' : (Color.DARK_BLUE, Color.LIGHT_BLUE), - 'msg_sender' : (Color.DARK_RED, Color.LIGHT_BLUE), - 'unread' : (Color.LIGHT_GRAY, Color.LIGHT_BLUE), - 'user_active' : (Color.LIGHT_GREEN__BOLD, Color.LIGHT_BLUE), - 'user_idle' : (Color.DARK_GRAY, Color.LIGHT_BLUE), - 'user_offline' : (Color.BLACK, Color.LIGHT_BLUE), - 'user_inactive' : (Color.BLACK, Color.LIGHT_BLUE), + 'general_bar' : (Color.DARK_BLUE, Background.COLOR), + 'msg_sender' : (Color.DARK_RED, Background.COLOR), + 'unread' : (Color.LIGHT_GRAY, Background.COLOR), + 'user_active' : (Color.LIGHT_GREEN__BOLD, Background.COLOR), + 'user_idle' : (Color.DARK_GRAY, Background.COLOR), + 'user_offline' : (Color.BLACK, Background.COLOR), + 'user_inactive' : (Color.BLACK, Background.COLOR), + 'user_bot' : (Color.BLACK, Background.COLOR), 'title' : (Color.WHITE__BOLD, Color.DARK_BLUE), - 'column_title' : (Color.BLACK__BOLD, Color.LIGHT_BLUE), - 'time' : (Color.DARK_BLUE, Color.LIGHT_BLUE), + 'column_title' : (Color.BLACK__BOLD, Background.COLOR), + 'time' : (Color.DARK_BLUE, Background.COLOR), 'bar' : (Color.WHITE, Color.DARK_BLUE), - 'msg_emoji' : (Color.DARK_MAGENTA, Color.LIGHT_BLUE), - 'reaction' : (Color.DARK_MAGENTA__BOLD, Color.LIGHT_BLUE), + 'msg_emoji' : (Color.DARK_MAGENTA, Background.COLOR), + 'reaction' : (Color.DARK_MAGENTA__BOLD, Background.COLOR), 'reaction_mine' : (Color.LIGHT_BLUE, Color.DARK_MAGENTA), 'msg_heading' : (Color.WHITE__BOLD, Color.BLACK), 'msg_math' : (Color.LIGHT_GRAY, Color.DARK_GRAY), - 'msg_mention' : (Color.LIGHT_RED__BOLD, Color.LIGHT_BLUE), + 'msg_mention' : (Color.LIGHT_RED__BOLD, Background.COLOR), 'msg_link' : (Color.DARK_BLUE, Color.LIGHT_GRAY), 'msg_link_index' : (Color.DARK_BLUE__BOLD, Color.LIGHT_GRAY), 'msg_quote' : (Color.BROWN, Color.DARK_BLUE), @@ -41,28 +43,28 @@ 'msg_time' : (Color.DARK_BLUE, Color.WHITE), 'footer' : (Color.WHITE, Color.DARK_GRAY), 'footer_contrast' : (Color.BLACK, Color.WHITE), - 'starred' : (Color.LIGHT_RED__BOLD, Color.LIGHT_BLUE), - 'unread_count' : (Color.YELLOW, Color.LIGHT_BLUE), - 'starred_count' : (Color.BLACK, Color.LIGHT_BLUE), - 'table_head' : (Color.BLACK__BOLD, Color.LIGHT_BLUE), + 'starred' : (Color.LIGHT_RED__BOLD, Background.COLOR), + 'unread_count' : (Color.YELLOW, Background.COLOR), + 'starred_count' : (Color.BLACK, Background.COLOR), + 'table_head' : (Color.BLACK__BOLD, Background.COLOR), 'filter_results' : (Color.WHITE, Color.DARK_GREEN), 'edit_topic' : (Color.WHITE, Color.DARK_BLUE), 'edit_tag' : (Color.WHITE, Color.DARK_BLUE), - 'edit_author' : (Color.DARK_GRAY, Color.LIGHT_BLUE), - 'edit_time' : (Color.DARK_BLUE, Color.LIGHT_BLUE), - 'current_user' : (Color.LIGHT_GRAY, Color.LIGHT_BLUE), - 'muted' : (Color.LIGHT_GRAY, Color.LIGHT_BLUE), - 'popup_border' : (Color.WHITE, Color.LIGHT_BLUE), - 'popup_category' : (Color.LIGHT_GRAY__BOLD, Color.LIGHT_BLUE), + 'edit_author' : (Color.DARK_GRAY, Background.COLOR), + 'edit_time' : (Color.DARK_BLUE, Background.COLOR), + 'current_user' : (Color.LIGHT_GRAY, Background.COLOR), + 'muted' : (Color.LIGHT_GRAY, Background.COLOR), + 'popup_border' : (Color.WHITE, Background.COLOR), + 'popup_category' : (Color.LIGHT_GRAY__BOLD, Background.COLOR), 'popup_contrast' : (Color.WHITE, Color.DARK_BLUE), - 'popup_important' : (Color.LIGHT_RED__BOLD, Color.LIGHT_BLUE), - 'widget_disabled' : (Color.DARK_GRAY, Color.LIGHT_BLUE), + 'popup_important' : (Color.LIGHT_RED__BOLD, Background.COLOR), + 'widget_disabled' : (Color.DARK_GRAY, Background.COLOR), 'area:help' : (Color.WHITE, Color.DARK_GREEN), 'area:stream' : (Color.WHITE, Color.DARK_CYAN), 'area:msg' : (Color.WHITE, Color.BROWN), 'area:error' : (Color.WHITE, Color.DARK_RED), 'area:user' : (Color.WHITE, Color.DARK_BLUE), - 'search_error' : (Color.LIGHT_RED, Color.LIGHT_BLUE), + 'search_error' : (Color.LIGHT_RED, Background.COLOR), 'task:success' : (Color.WHITE, Color.DARK_GREEN), 'task:error' : (Color.WHITE, Color.DARK_RED), 'task:warning' : (Color.WHITE, Color.BROWN), @@ -70,6 +72,7 @@ } META = { + 'background': Color.LIGHT_BLUE, 'pygments': { 'styles' : ZenburnStyle().styles, 'background': 'h25', diff --git a/zulipterminal/themes/zt_dark.py b/zulipterminal/themes/zt_dark.py index 36791644ee..447abd7438 100644 --- a/zulipterminal/themes/zt_dark.py +++ b/zulipterminal/themes/zt_dark.py @@ -6,63 +6,65 @@ """ from pygments.styles.material import MaterialStyle +from zulipterminal.config.color import Background from zulipterminal.config.color import DefaultBoldColor as Color # fmt: off STYLES = { # style_name : foreground background - None : (Color.WHITE, Color.BLACK), + None : (Color.WHITE, Background.COLOR), 'selected' : (Color.WHITE, Color.DARK_BLUE), 'msg_selected' : (Color.WHITE, Color.DARK_BLUE), 'header' : (Color.DARK_CYAN, Color.DARK_BLUE), 'general_narrow' : (Color.WHITE, Color.DARK_BLUE), - 'general_bar' : (Color.WHITE, Color.BLACK), - 'msg_sender' : (Color.YELLOW__BOLD, Color.BLACK), - 'unread' : (Color.DARK_BLUE, Color.BLACK), - 'user_active' : (Color.LIGHT_GREEN, Color.BLACK), - 'user_idle' : (Color.YELLOW, Color.BLACK), - 'user_offline' : (Color.WHITE, Color.BLACK), - 'user_inactive' : (Color.WHITE, Color.BLACK), - 'title' : (Color.WHITE__BOLD, Color.BLACK), - 'column_title' : (Color.WHITE__BOLD, Color.BLACK), - 'time' : (Color.LIGHT_BLUE, Color.BLACK), + 'general_bar' : (Color.WHITE, Background.COLOR), + 'msg_sender' : (Color.YELLOW__BOLD, Background.COLOR), + 'unread' : (Color.DARK_BLUE, Background.COLOR), + 'user_active' : (Color.LIGHT_GREEN, Background.COLOR), + 'user_idle' : (Color.YELLOW, Background.COLOR), + 'user_offline' : (Color.WHITE, Background.COLOR), + 'user_inactive' : (Color.WHITE, Background.COLOR), + 'user_bot' : (Color.WHITE, Background.COLOR), + 'title' : (Color.WHITE__BOLD, Background.COLOR), + 'column_title' : (Color.WHITE__BOLD, Background.COLOR), + 'time' : (Color.LIGHT_BLUE, Background.COLOR), 'bar' : (Color.WHITE, Color.DARK_GRAY), - 'msg_emoji' : (Color.LIGHT_MAGENTA, Color.BLACK), - 'reaction' : (Color.LIGHT_MAGENTA__BOLD, Color.BLACK), + 'msg_emoji' : (Color.LIGHT_MAGENTA, Background.COLOR), + 'reaction' : (Color.LIGHT_MAGENTA__BOLD, Background.COLOR), 'reaction_mine' : (Color.BLACK, Color.LIGHT_MAGENTA), 'msg_heading' : (Color.LIGHT_CYAN__BOLD, Color.DARK_MAGENTA), 'msg_math' : (Color.LIGHT_GRAY, Color.DARK_GRAY), - 'msg_mention' : (Color.LIGHT_RED__BOLD, Color.BLACK), - 'msg_link' : (Color.LIGHT_BLUE, Color.BLACK), - 'msg_link_index' : (Color.LIGHT_BLUE__BOLD, Color.BLACK), - 'msg_quote' : (Color.BROWN, Color.BLACK), - 'msg_bold' : (Color.WHITE__BOLD, Color.BLACK), + 'msg_mention' : (Color.LIGHT_RED__BOLD, Background.COLOR), + 'msg_link' : (Color.LIGHT_BLUE, Background.COLOR), + 'msg_link_index' : (Color.LIGHT_BLUE__BOLD, Background.COLOR), + 'msg_quote' : (Color.BROWN, Background.COLOR), + 'msg_bold' : (Color.WHITE__BOLD, Background.COLOR), 'msg_time' : (Color.BLACK, Color.WHITE), 'footer' : (Color.BLACK, Color.LIGHT_GRAY), - 'footer_contrast' : (Color.WHITE, Color.BLACK), - 'starred' : (Color.LIGHT_RED__BOLD, Color.BLACK), - 'unread_count' : (Color.YELLOW, Color.BLACK), - 'starred_count' : (Color.LIGHT_GRAY, Color.BLACK), - 'table_head' : (Color.WHITE__BOLD, Color.BLACK), + 'footer_contrast' : (Color.WHITE, Background.COLOR), + 'starred' : (Color.LIGHT_RED__BOLD, Background.COLOR), + 'unread_count' : (Color.YELLOW, Background.COLOR), + 'starred_count' : (Color.LIGHT_GRAY, Background.COLOR), + 'table_head' : (Color.WHITE__BOLD, Background.COLOR), 'filter_results' : (Color.WHITE, Color.DARK_GREEN), 'edit_topic' : (Color.WHITE, Color.DARK_GRAY), 'edit_tag' : (Color.WHITE, Color.DARK_GRAY), - 'edit_author' : (Color.YELLOW, Color.BLACK), - 'edit_time' : (Color.LIGHT_BLUE, Color.BLACK), - 'current_user' : (Color.WHITE, Color.BLACK), - 'muted' : (Color.LIGHT_BLUE, Color.BLACK), - 'popup_border' : (Color.WHITE, Color.BLACK), - 'popup_category' : (Color.LIGHT_BLUE__BOLD, Color.BLACK), + 'edit_author' : (Color.YELLOW, Background.COLOR), + 'edit_time' : (Color.LIGHT_BLUE, Background.COLOR), + 'current_user' : (Color.WHITE, Background.COLOR), + 'muted' : (Color.LIGHT_BLUE, Background.COLOR), + 'popup_border' : (Color.WHITE, Background.COLOR), + 'popup_category' : (Color.LIGHT_BLUE__BOLD, Background.COLOR), 'popup_contrast' : (Color.WHITE, Color.DARK_GRAY), - 'popup_important' : (Color.LIGHT_RED__BOLD, Color.BLACK), - 'widget_disabled' : (Color.DARK_GRAY, Color.BLACK), + 'popup_important' : (Color.LIGHT_RED__BOLD, Background.COLOR), + 'widget_disabled' : (Color.DARK_GRAY, Background.COLOR), 'area:help' : (Color.WHITE, Color.DARK_GREEN), 'area:msg' : (Color.WHITE, Color.BROWN), 'area:stream' : (Color.WHITE, Color.DARK_CYAN), 'area:error' : (Color.WHITE, Color.DARK_RED), 'area:user' : (Color.WHITE, Color.DARK_BLUE), - 'search_error' : (Color.LIGHT_RED, Color.BLACK), + 'search_error' : (Color.LIGHT_RED, Background.COLOR), 'task:success' : (Color.WHITE, Color.DARK_GREEN), 'task:error' : (Color.WHITE, Color.DARK_RED), 'task:warning' : (Color.WHITE, Color.BROWN), @@ -70,6 +72,7 @@ } META = { + 'background': Color.BLACK, 'pygments': { 'styles' : MaterialStyle().styles, 'background': 'h235', diff --git a/zulipterminal/themes/zt_light.py b/zulipterminal/themes/zt_light.py index 1ca6b94547..388a9c6770 100644 --- a/zulipterminal/themes/zt_light.py +++ b/zulipterminal/themes/zt_light.py @@ -6,63 +6,65 @@ """ from pygments.styles.perldoc import PerldocStyle +from zulipterminal.config.color import Background from zulipterminal.config.color import DefaultBoldColor as Color # fmt: off STYLES = { # style_name : foreground background - None : (Color.BLACK, Color.WHITE), + None : (Color.BLACK, Background.COLOR), 'selected' : (Color.BLACK, Color.LIGHT_GREEN), 'msg_selected' : (Color.BLACK, Color.LIGHT_GREEN), 'header' : (Color.WHITE, Color.DARK_BLUE), 'general_narrow' : (Color.WHITE, Color.DARK_BLUE), - 'general_bar' : (Color.DARK_BLUE, Color.WHITE), - 'msg_sender' : (Color.DARK_GREEN, Color.WHITE), + 'general_bar' : (Color.DARK_BLUE, Background.COLOR), + 'msg_sender' : (Color.DARK_GREEN, Background.COLOR), 'unread' : (Color.DARK_GRAY, Color.LIGHT_GRAY), - 'user_active' : (Color.DARK_GREEN, Color.WHITE), - 'user_idle' : (Color.DARK_BLUE, Color.WHITE), - 'user_offline' : (Color.BLACK, Color.WHITE), - 'user_inactive' : (Color.BLACK, Color.WHITE), + 'user_active' : (Color.DARK_GREEN, Background.COLOR), + 'user_idle' : (Color.DARK_BLUE, Background.COLOR), + 'user_offline' : (Color.BLACK, Background.COLOR), + 'user_inactive' : (Color.BLACK, Background.COLOR), + 'user_bot' : (Color.BLACK, Background.COLOR), 'title' : (Color.WHITE__BOLD, Color.DARK_GRAY), - 'column_title' : (Color.BLACK__BOLD, Color.WHITE), - 'time' : (Color.DARK_BLUE, Color.WHITE), + 'column_title' : (Color.BLACK__BOLD, Background.COLOR), + 'time' : (Color.DARK_BLUE, Background.COLOR), 'bar' : (Color.WHITE, Color.DARK_GRAY), - 'msg_emoji' : (Color.LIGHT_MAGENTA, Color.WHITE), - 'reaction' : (Color.LIGHT_MAGENTA__BOLD, Color.WHITE), + 'msg_emoji' : (Color.LIGHT_MAGENTA, Background.COLOR), + 'reaction' : (Color.LIGHT_MAGENTA__BOLD, Background.COLOR), 'reaction_mine' : (Color.WHITE, Color.LIGHT_MAGENTA), 'msg_heading' : (Color.WHITE__BOLD, Color.DARK_RED), 'msg_math' : (Color.DARK_GRAY, Color.LIGHT_GRAY), - 'msg_mention' : (Color.LIGHT_RED__BOLD, Color.WHITE), - 'msg_link' : (Color.DARK_BLUE, Color.WHITE), - 'msg_link_index' : (Color.DARK_BLUE__BOLD, Color.WHITE), + 'msg_mention' : (Color.LIGHT_RED__BOLD, Background.COLOR), + 'msg_link' : (Color.DARK_BLUE, Background.COLOR), + 'msg_link_index' : (Color.DARK_BLUE__BOLD, Background.COLOR), 'msg_quote' : (Color.BLACK, Color.BROWN), 'msg_bold' : (Color.WHITE__BOLD, Color.DARK_GRAY), 'msg_time' : (Color.WHITE, Color.DARK_GRAY), 'footer' : (Color.WHITE, Color.DARK_GRAY), - 'footer_contrast' : (Color.BLACK, Color.WHITE), - 'starred' : (Color.LIGHT_RED__BOLD, Color.WHITE), - 'unread_count' : (Color.DARK_BLUE__BOLD, Color.WHITE), - 'starred_count' : (Color.BLACK, Color.WHITE), - 'table_head' : (Color.BLACK__BOLD, Color.WHITE), + 'footer_contrast' : (Color.BLACK, Background.COLOR), + 'starred' : (Color.LIGHT_RED__BOLD, Background.COLOR), + 'unread_count' : (Color.DARK_BLUE__BOLD, Background.COLOR), + 'starred_count' : (Color.BLACK, Background.COLOR), + 'table_head' : (Color.BLACK__BOLD, Background.COLOR), 'filter_results' : (Color.WHITE, Color.DARK_GREEN), 'edit_topic' : (Color.WHITE, Color.DARK_GRAY), 'edit_tag' : (Color.WHITE, Color.DARK_GRAY), - 'edit_author' : (Color.DARK_GREEN, Color.WHITE), - 'edit_time' : (Color.DARK_BLUE, Color.WHITE), - 'current_user' : (Color.DARK_GRAY, Color.WHITE), - 'muted' : (Color.DARK_GRAY, Color.WHITE), - 'popup_border' : (Color.BLACK, Color.WHITE), + 'edit_author' : (Color.DARK_GREEN, Background.COLOR), + 'edit_time' : (Color.DARK_BLUE, Background.COLOR), + 'current_user' : (Color.DARK_GRAY, Background.COLOR), + 'muted' : (Color.DARK_GRAY, Background.COLOR), + 'popup_border' : (Color.BLACK, Background.COLOR), 'popup_category' : (Color.DARK_GRAY__BOLD, Color.LIGHT_GRAY), 'popup_contrast' : (Color.WHITE, Color.DARK_GRAY), - 'popup_important' : (Color.LIGHT_RED__BOLD, Color.WHITE), - 'widget_disabled' : (Color.LIGHT_GRAY, Color.WHITE), + 'popup_important' : (Color.LIGHT_RED__BOLD, Background.COLOR), + 'widget_disabled' : (Color.LIGHT_GRAY, Background.COLOR), 'area:help' : (Color.BLACK, Color.LIGHT_GREEN), 'area:stream' : (Color.BLACK, Color.LIGHT_BLUE), 'area:msg' : (Color.BLACK, Color.YELLOW), 'area:error' : (Color.BLACK, Color.LIGHT_RED), 'area:user' : (Color.WHITE, Color.DARK_BLUE), - 'search_error' : (Color.LIGHT_RED, Color.WHITE), + 'search_error' : (Color.LIGHT_RED, Background.COLOR), 'task:success' : (Color.BLACK, Color.DARK_GREEN), 'task:error' : (Color.WHITE, Color.DARK_RED), 'task:warning' : (Color.BLACK, Color.YELLOW), @@ -70,6 +72,7 @@ } META = { + 'background': Color.WHITE, 'pygments': { 'styles' : PerldocStyle().styles, 'background': PerldocStyle().background_color, diff --git a/zulipterminal/ui.py b/zulipterminal/ui.py index fa140634ce..d0785bd928 100644 --- a/zulipterminal/ui.py +++ b/zulipterminal/ui.py @@ -5,15 +5,20 @@ import random import re import time -from typing import Any, List, Optional +from typing import Any, Dict, List, Optional import urwid -from zulipterminal.config.keys import commands_for_random_tips, is_command_key +from zulipterminal.config.keys import ( + commands_for_random_tips, + display_key_for_urwid_key, + is_command_key, +) from zulipterminal.config.symbols import ( APPLICATION_TITLE_BAR_LINE, AUTOHIDE_TAB_LEFT_ARROW, AUTOHIDE_TAB_RIGHT_ARROW, + COLUMN_DIVIDER_LINE, COLUMN_TITLE_BAR_LINE, ) from zulipterminal.config.ui_sizes import LEFT_WIDTH, RIGHT_WIDTH, TAB_WIDTH @@ -43,12 +48,19 @@ def __init__(self, controller: Any) -> None: self.unpinned_streams = self.model.unpinned_streams self.write_box = WriteBox(self) self.search_box = MessageSearchBox(self.controller) + self.stream_topic_map: Dict[int, str] = {} self.message_view: Any = None self.displaying_selection_hint = False super().__init__(self.main_window()) + def associate_stream_with_topic(self, stream_id: int, topic_name: str) -> None: + self.stream_topic_map[stream_id] = topic_name + + def saved_topic_in_stream_id(self, stream_id: int) -> Optional[str]: + return self.stream_topic_map.get(stream_id, None) + def left_column_view(self) -> Any: tab = TabView( f"{AUTOHIDE_TAB_LEFT_ARROW} STREAMS & TOPICS {AUTOHIDE_TAB_LEFT_ARROW}" @@ -66,8 +78,8 @@ def middle_column_view(self) -> Any: title_attr="column_title", tline=COLUMN_TITLE_BAR_LINE, bline="", - trcorner="│", - tlcorner="│", + trcorner=COLUMN_DIVIDER_LINE, + tlcorner=COLUMN_DIVIDER_LINE, ) def right_column_view(self) -> Any: @@ -81,7 +93,7 @@ def right_column_view(self) -> Any: tline=COLUMN_TITLE_BAR_LINE, trcorner=COLUMN_TITLE_BAR_LINE, lline="", - blcorner="─", + blcorner="", rline="", bline="", brcorner="", @@ -94,10 +106,13 @@ def get_random_help(self) -> List[Any]: if not allowed_commands: return ["Help(?): "] random_command = random.choice(allowed_commands) + random_command_display_keys = ", ".join( + [display_key_for_urwid_key(key) for key in random_command["keys"]] + ) return [ "Help(?): ", - ("footer_contrast", " " + ", ".join(random_command["keys"]) + " "), - " " + random_command["help_text"], + ("footer_contrast", f" {random_command_display_keys} "), + f" {random_command['help_text']}", ] @asynch @@ -313,6 +328,9 @@ def keypress(self, size: urwid_Box, key: str) -> Optional[str]: elif is_command_key("MARKDOWN_HELP", key): self.controller.show_markdown_help() return key + elif is_command_key("NEW_HINT", key): + self.set_footer_text() + return key return super().keypress(size, key) def mouse_event( diff --git a/zulipterminal/ui_tools/boxes.py b/zulipterminal/ui_tools/boxes.py index 7fc848b95c..1a479cadad 100644 --- a/zulipterminal/ui_tools/boxes.py +++ b/zulipterminal/ui_tools/boxes.py @@ -3,26 +3,25 @@ """ import re +import shlex +import shutil +import subprocess import unicodedata -from collections import Counter, OrderedDict +from collections import Counter from datetime import datetime, timedelta +from tempfile import NamedTemporaryFile from time import sleep from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple import urwid -from typing_extensions import Literal +from typing_extensions import Final, Literal from urwid_readline import ReadlineEdit -from zulipterminal.api_types import ( - TYPING_STARTED_WAIT_PERIOD, - TYPING_STOPPED_WAIT_PERIOD, - Composition, - PrivateComposition, - StreamComposition, -) +from zulipterminal.api_types import Composition, PrivateComposition, StreamComposition from zulipterminal.config.keys import ( + display_keys_for_command, is_command_key, - keys_for_command, + primary_display_key_for_command, primary_key_for_command, ) from zulipterminal.config.regexes import ( @@ -32,7 +31,13 @@ REGEX_STREAM_AND_TOPIC_FENCED_HALF, REGEX_STREAM_AND_TOPIC_UNFENCED, ) -from zulipterminal.config.symbols import INVALID_MARKER, STREAM_TOPIC_SEPARATOR +from zulipterminal.config.symbols import ( + COMPOSE_HEADER_BOTTOM, + COMPOSE_HEADER_TOP, + INVALID_MARKER, + MESSAGE_RECIPIENTS_BORDER, + STREAM_TOPIC_SEPARATOR, +) from zulipterminal.config.ui_mappings import STREAM_ACCESS_TYPE from zulipterminal.helper import ( asynch, @@ -48,6 +53,11 @@ from zulipterminal.urwid_types import urwid_Size +# This constant defines the maximum character length of a message +# in the compose box that does not trigger a confirmation popup. +MAX_MESSAGE_LENGTH_CONFIRMATION_POPUP: Final = 15 + + class _MessageEditState(NamedTuple): message_id: int old_topic: str @@ -218,16 +228,12 @@ def private_box_view( self.msg_write_box.set_completer_delims(DELIMS_MESSAGE_COMPOSE) self.header_write_box = urwid.Columns([self.to_write_box]) - header_line_box = urwid.LineBox( - self.header_write_box, - tlcorner="━", - tline="━", - trcorner="━", - lline="", - blcorner="─", - bline="─", - brcorner="─", - rline="", + header_line_box = urwid.Pile( + [ + urwid.Divider(COMPOSE_HEADER_TOP), + self.header_write_box, + urwid.Divider(COMPOSE_HEADER_BOTTOM), + ] ) self.contents = [ (header_line_box, self.options()), @@ -235,8 +241,12 @@ def private_box_view( ] self.focus_position = self.FOCUS_CONTAINER_MESSAGE - start_period_delta = timedelta(seconds=TYPING_STARTED_WAIT_PERIOD) - stop_period_delta = timedelta(seconds=TYPING_STOPPED_WAIT_PERIOD) + start_period_delta = timedelta( + milliseconds=self.model.typing_started_wait_period + ) + stop_period_delta = timedelta( + milliseconds=self.model.typing_stopped_wait_period + ) def on_type_send_status(edit: object, new_edit_text: str) -> None: if new_edit_text and self.typing_recipient_user_ids: @@ -302,11 +312,11 @@ def _tidy_valid_recipients_and_notify_invalid_ones( invalid_recipients_error = [ "Invalid recipient(s) - " + ", ".join(invalid_recipients), " - Use ", - ("footer_contrast", primary_key_for_command("AUTOCOMPLETE")), + ("footer_contrast", primary_display_key_for_command("AUTOCOMPLETE")), " or ", ( "footer_contrast", - primary_key_for_command("AUTOCOMPLETE_REVERSE"), + primary_display_key_for_command("AUTOCOMPLETE_REVERSE"), ), " to autocomplete.", ] @@ -354,16 +364,12 @@ def _setup_common_stream_compose( ], dividechars=1, ) - header_line_box = urwid.LineBox( - self.header_write_box, - tlcorner="━", - tline="━", - trcorner="━", - lline="", - blcorner="─", - bline="─", - brcorner="─", - rline="", + header_line_box = urwid.Pile( + [ + urwid.Divider(COMPOSE_HEADER_TOP), + self.header_write_box, + urwid.Divider(COMPOSE_HEADER_BOTTOM), + ] ) write_box = [ (header_line_box, self.options()), @@ -467,18 +473,16 @@ def _stream_box_autocomplete( return self._process_typeaheads(matched_streams[0], state, matched_streams[1]) def generic_autocomplete(self, text: str, state: Optional[int]) -> Optional[str]: - autocomplete_map = OrderedDict( - [ - ("@_", self.autocomplete_users), - ("@_**", self.autocomplete_users), - ("@", self.autocomplete_mentions), - ("@*", self.autocomplete_groups), - ("@**", self.autocomplete_users), - ("#", self.autocomplete_streams), - ("#**", self.autocomplete_streams), - (":", self.autocomplete_emojis), - ] - ) + autocomplete_map = { + "@_": self.autocomplete_users, + "@_**": self.autocomplete_users, + "@": self.autocomplete_mentions, + "@*": self.autocomplete_groups, + "@**": self.autocomplete_users, + "#": self.autocomplete_streams, + "#**": self.autocomplete_streams, + ":": self.autocomplete_emojis, + } # Look in a reverse order to find the last autocomplete prefix used in # the text. For instance, if text='@#example', use '#' as the prefix. @@ -673,7 +677,7 @@ def autocomplete_stream_and_topic( def validate_and_patch_autocomplete_stream_and_topic( self, text: str, - autocomplete_map: "OrderedDict[str, Callable[..., Any]]", + autocomplete_map: Dict[str, Callable[..., Any]], prefix_indices: Dict[str, int], ) -> str: """ @@ -714,14 +718,28 @@ def autocomplete_emojis( return emoji_typeahead, emojis + def exit_compose_box(self) -> None: + self._set_default_footer_after_autocomplete() + self._set_compose_attributes_to_defaults() + self.view.controller.exit_editor_mode() + self.main_view(False) + self.view.middle_column.set_focus("body") + + def _set_default_footer_after_autocomplete(self) -> None: + self.is_in_typeahead_mode = False + self.view.set_footer_text() + def keypress(self, size: urwid_Size, key: str) -> Optional[str]: if self.is_in_typeahead_mode and not ( is_command_key("AUTOCOMPLETE", key) or is_command_key("AUTOCOMPLETE_REVERSE", key) ): - # set default footer when done with autocomplete - self.is_in_typeahead_mode = False - self.view.set_footer_text() + # As is, this exits autocomplete even if the user chooses to resume compose. + # Including a check for "EXIT_COMPOSE" in the above logic would avoid + # resetting the footer until actually exiting compose, but autocomplete + # itself does not continue on resume with such a solution. + # TODO: Fully implement resuming of autocomplete upon resuming compose. + self._set_default_footer_after_autocomplete() if is_command_key("SEND_MESSAGE", key): self.send_stop_typing_status() @@ -780,7 +798,7 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: if success: self.msg_write_box.edit_text = "" if self.msg_edit_state is not None: - self.keypress(size, primary_key_for_command("GO_BACK")) + self.keypress(size, primary_key_for_command("EXIT_COMPOSE")) assert self.msg_edit_state is None elif is_command_key("NARROW_MESSAGE_RECIPIENT", key): if self.compose_box_status == "open_with_stream": @@ -803,15 +821,69 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: self.view.controller.report_error( "Cannot narrow to message without specifying recipients." ) - elif is_command_key("GO_BACK", key): + elif is_command_key("EXIT_COMPOSE", key): + saved_draft = self.model.session_draft_message() self.send_stop_typing_status() - self._set_compose_attributes_to_defaults() - self.view.controller.exit_editor_mode() - self.main_view(False) - self.view.middle_column.set_focus("body") + + compose_not_in_edit_mode = self.msg_edit_state is None + compose_box_content = self.msg_write_box.edit_text + saved_draft_content = saved_draft.get("content") if saved_draft else None + + exceeds_max_length = ( + len(compose_box_content) >= MAX_MESSAGE_LENGTH_CONFIRMATION_POPUP + ) + not_saved_as_draft = ( + saved_draft is None or compose_box_content != saved_draft_content + ) + + if compose_not_in_edit_mode and exceeds_max_length and not_saved_as_draft: + self.view.controller.exit_compose_confirmation_popup() + else: + self.exit_compose_box() elif is_command_key("MARKDOWN_HELP", key): self.view.controller.show_markdown_help() return key + elif is_command_key("OPEN_EXTERNAL_EDITOR", key): + editor_command = self.view.controller.editor_command + + # None would indicate for shlex.split to read sys.stdin for Python < 3.12 + # It should never occur in practice + assert isinstance(editor_command, str) + + if editor_command == "": + self.view.controller.report_error( + "No external editor command specified; " + "Set 'editor' in zuliprc file, or " + "$ZULIP_EDITOR_COMMAND or $EDITOR environment variables." + ) + return key + + editor_command_line: List[str] = shlex.split(editor_command) + if not editor_command_line: + fullpath_program = None # A command may be specified, but empty + else: + fullpath_program = shutil.which(editor_command_line[0]) + if fullpath_program is None: + self.view.controller.report_error( + "External editor command not found; " + "Check your zuliprc file, $EDITOR or $ZULIP_EDITOR_COMMAND." + ) + return key + editor_command_line[0] = fullpath_program + + with NamedTemporaryFile(suffix=".md") as edit_tempfile: + with open(edit_tempfile.name, mode="w") as edit_writer: + edit_writer.write(self.msg_write_box.edit_text) + self.view.controller.loop.screen.stop() + + editor_command_line.append(edit_tempfile.name) + subprocess.call(editor_command_line) + + with open(edit_tempfile.name, mode="r") as edit_reader: + self.msg_write_box.edit_text = edit_reader.read().rstrip() + self.view.controller.loop.screen.start() + return key + elif is_command_key("SAVE_AS_DRAFT", key): if self.msg_edit_state is None: if self.compose_box_status == "open_with_private": @@ -825,6 +897,7 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: type="private", to=self.recipient_user_ids, content=self.msg_write_box.edit_text, + read_by_sender=True, ) elif self.compose_box_status == "open_with_stream": this_draft = StreamComposition( @@ -832,6 +905,7 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: to=self.stream_write_box.edit_text, content=self.msg_write_box.edit_text, subject=self.title_write_box.edit_text, + read_by_sender=True, ) saved_draft = self.model.session_draft_message() if not saved_draft: @@ -856,8 +930,10 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: invalid_stream_error = ( "Invalid stream name." " Use {} or {} to autocomplete.".format( - primary_key_for_command("AUTOCOMPLETE"), - primary_key_for_command("AUTOCOMPLETE_REVERSE"), + primary_display_key_for_command("AUTOCOMPLETE"), + primary_display_key_for_command( + "AUTOCOMPLETE_REVERSE" + ), ) ) self.view.controller.report_error([invalid_stream_error]) @@ -924,7 +1000,9 @@ def __init__(self, controller: Any) -> None: super().__init__(self.main_view()) def main_view(self) -> Any: - search_text = f"Search [{', '.join(keys_for_command('SEARCH_MESSAGES'))}]: " + search_text = ( + f"Search [{', '.join(display_keys_for_command('SEARCH_MESSAGES'))}]: " + ) self.text_box = ReadlineEdit(f"{search_text} ") # Add some text so that when packing, # urwid doesn't hide the widget. @@ -932,7 +1010,7 @@ def main_view(self) -> Any: self.search_bar = urwid.Columns( [ ("pack", self.conversation_focus), - ("pack", urwid.Text(" ")), + ("pack", urwid.Text(" ")), self.text_box, ] ) @@ -940,27 +1018,20 @@ def main_view(self) -> Any: self.recipient_bar = urwid.LineBox( self.msg_narrow, title="Current message recipients", - tline="─", - lline="", - trcorner="─", - tlcorner="─", - blcorner="─", - rline="", - bline="─", - brcorner="─", + **MESSAGE_RECIPIENTS_BORDER, ) return [self.search_bar, self.recipient_bar] def keypress(self, size: urwid_Size, key: str) -> Optional[str]: if ( - is_command_key("ENTER", key) and self.text_box.edit_text == "" - ) or is_command_key("GO_BACK", key): + is_command_key("EXECUTE_SEARCH", key) and self.text_box.edit_text == "" + ) or is_command_key("CLEAR_SEARCH", key): self.text_box.set_edit_text("") self.controller.exit_editor_mode() self.controller.view.middle_column.set_focus("body") return key - elif is_command_key("ENTER", key): + elif is_command_key("EXECUTE_SEARCH", key): self.controller.exit_editor_mode() self.controller.search_messages(self.text_box.edit_text) self.controller.view.middle_column.set_focus("body") @@ -970,7 +1041,7 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: return key -class PanelSearchBox(urwid.Edit): +class PanelSearchBox(ReadlineEdit): """ Search Box to search panel views in real-time. """ @@ -980,7 +1051,9 @@ def __init__( ) -> None: self.panel_view = panel_view self.search_command = search_command - self.search_text = f" Search [{', '.join(keys_for_command(search_command))}]: " + self.search_text = ( + f" Search [{', '.join(display_keys_for_command(search_command))}]: " + ) self.search_error = urwid.AttrMap( urwid.Text([" ", INVALID_MARKER, " No Results"]), "search_error" ) @@ -1007,15 +1080,15 @@ def valid_char(self, ch: str) -> bool: def keypress(self, size: urwid_Size, key: str) -> Optional[str]: if ( - is_command_key("ENTER", key) and self.get_edit_text() == "" - ) or is_command_key("GO_BACK", key): + is_command_key("EXECUTE_SEARCH", key) and self.get_edit_text() == "" + ) or is_command_key("CLEAR_SEARCH", key): self.panel_view.view.controller.exit_editor_mode() self.reset_search_text() self.panel_view.set_focus("body") # Don't call 'Esc' when inside a popup search-box. if not self.panel_view.view.controller.is_any_popup_open(): - self.panel_view.keypress(size, primary_key_for_command("GO_BACK")) - elif is_command_key("ENTER", key) and not self.panel_view.empty_search: + self.panel_view.keypress(size, primary_key_for_command("CLEAR_SEARCH")) + elif is_command_key("EXECUTE_SEARCH", key) and not self.panel_view.empty_search: self.panel_view.view.controller.exit_editor_mode() self.set_caption([("filter_results", " Search Results "), " "]) self.panel_view.set_focus("body") diff --git a/zulipterminal/ui_tools/buttons.py b/zulipterminal/ui_tools/buttons.py index 5b1584da77..8e67d79adf 100644 --- a/zulipterminal/ui_tools/buttons.py +++ b/zulipterminal/ui_tools/buttons.py @@ -10,12 +10,23 @@ import urwid from typing_extensions import TypedDict -from zulipterminal.api_types import RESOLVED_TOPIC_PREFIX, EditPropagateMode -from zulipterminal.config.keys import is_command_key, primary_key_for_command +from zulipterminal.api_types import RESOLVED_TOPIC_PREFIX, EditPropagateMode, Message +from zulipterminal.config.keys import ( + is_command_key, + primary_display_key_for_command, + primary_key_for_command, +) from zulipterminal.config.regexes import REGEX_INTERNAL_LINK_STREAM_ID -from zulipterminal.config.symbols import CHECK_MARK, MUTE_MARKER +from zulipterminal.config.symbols import ( + ALL_MESSAGES_MARKER, + CHECK_MARK, + DIRECT_MESSAGE_MARKER, + MENTIONED_MESSAGES_MARKER, + MUTE_MARKER, + STARRED_MESSAGES_MARKER, +) from zulipterminal.config.ui_mappings import EDIT_MODE_CAPTIONS, STREAM_ACCESS_TYPE -from zulipterminal.helper import Message, StreamData, hash_util_decode, process_media +from zulipterminal.helper import StreamData, hash_util_decode, process_media from zulipterminal.urwid_types import urwid_MarkupTuple, urwid_Size @@ -109,7 +120,7 @@ def activate(self, key: Any) -> None: self.show_function() def keypress(self, size: urwid_Size, key: str) -> Optional[str]: - if is_command_key("ENTER", key): + if is_command_key("ACTIVATE_BUTTON", key): self.activate(key) return None else: # This is in the else clause, to avoid multiple activation @@ -118,10 +129,13 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: class HomeButton(TopButton): def __init__(self, *, controller: Any, count: int) -> None: - button_text = f"All messages [{primary_key_for_command('ALL_MESSAGES')}]" + button_text = ( + f"All messages [{primary_display_key_for_command('ALL_MESSAGES')}]" + ) super().__init__( controller=controller, + prefix_markup=("title", ALL_MESSAGES_MARKER), label_markup=(None, button_text), suffix_markup=("unread_count", ""), show_function=controller.narrow_to_all_messages, @@ -131,11 +145,12 @@ def __init__(self, *, controller: Any, count: int) -> None: class PMButton(TopButton): def __init__(self, *, controller: Any, count: int) -> None: - button_text = f"Direct messages [{primary_key_for_command('ALL_PM')}]" + button_text = f"Direct messages [{primary_display_key_for_command('ALL_PM')}]" super().__init__( controller=controller, label_markup=(None, button_text), + prefix_markup=("title", DIRECT_MESSAGE_MARKER), suffix_markup=("unread_count", ""), show_function=controller.narrow_to_all_pm, count=count, @@ -144,10 +159,13 @@ def __init__(self, *, controller: Any, count: int) -> None: class MentionedButton(TopButton): def __init__(self, *, controller: Any, count: int) -> None: - button_text = f"Mentions [{primary_key_for_command('ALL_MENTIONS')}]" + button_text = ( + f"Mentions [{primary_display_key_for_command('ALL_MENTIONS')}]" + ) super().__init__( controller=controller, + prefix_markup=("title", MENTIONED_MESSAGES_MARKER), label_markup=(None, button_text), suffix_markup=("unread_count", ""), show_function=controller.narrow_to_all_mentions, @@ -157,10 +175,13 @@ def __init__(self, *, controller: Any, count: int) -> None: class StarredButton(TopButton): def __init__(self, *, controller: Any, count: int) -> None: - button_text = f"Starred messages [{primary_key_for_command('ALL_STARRED')}]" + button_text = ( + f"Starred messages [{primary_display_key_for_command('ALL_STARRED')}]" + ) super().__init__( controller=controller, + prefix_markup=("title", STARRED_MESSAGES_MARKER), label_markup=(None, button_text), suffix_markup=("starred_count", ""), show_function=controller.narrow_to_all_starred, @@ -189,16 +210,26 @@ def __init__( self.count = count self.view = view + # FIXME: This section should be moved to a general palette-extension method + # which is triggered when streams are initially loaded and when colors are + # adjusted, or new streams added for entry in view.palette: if entry[0] is None: + # NOTE: entry is generated at runtime, so length is dynamic + # entry[5] is 256-color background entry + # entry[2] is 16-color background entry background = entry[5] if len(entry) > 4 else entry[2] - inverse_text = background if background else "black" + inverse_text = "black" if background in ["default", ""] else background break + # These tuples represent (new) Urwid palette entries for the stream color: + # (style_name, 16-color fg, 16-color bg, mono, 256+color fg, 256+color bg) + # The normalized color becomes a named style (eg. "#abc") for colored foreground view.palette.append( - (self.color, "", "", "bold", f"{self.color}, bold", background) + (f"{self.color}", "", "", "bold", f"{self.color}, bold", background) ) + # The s-prefixed style name (eg "s#abc") is used for inverted styling view.palette.append( - ("s" + self.color, "", "", "standout", inverse_text, self.color) + (f"s{self.color}", "", "", "standout", inverse_text, self.color) ) stream_marker = STREAM_ACCESS_TYPE[stream_access_type]["icon"] @@ -348,6 +379,7 @@ def mark_muted(self) -> None: def keypress(self, size: urwid_Size, key: str) -> Optional[str]: if is_command_key("TOGGLE_TOPIC", key): # Exit topic view + self.view.associate_stream_with_topic(self.stream_id, self.topic_name) self.view.left_panel.show_stream_view() return super().keypress(size, key) @@ -395,7 +427,7 @@ def mouse_event( self, size: urwid_Size, event: str, button: int, col: int, row: int, focus: int ) -> bool: if event == "mouse press" and button == 1: - self.keypress(size, primary_key_for_command("ENTER")) + self.keypress(size, primary_key_for_command("ACTIVATE_BUTTON")) return True return super().mouse_event(size, event, button, col, row, focus) @@ -493,12 +525,17 @@ def _parse_narrow_link(cls, link: str) -> ParsedNarrowLink: """ # NOTE: The optional stream_id link version is deprecated. The extended # support is for old messages. + # NOTE: Support for narrow links with subject instead of topic is also added # We expect the fragment to be one of the following types: # a. narrow/stream/[{stream_id}-]{stream-name} # b. narrow/stream/[{stream_id}-]{stream-name}/near/{message_id} # c. narrow/stream/[{stream_id}-]{stream-name}/topic/ # {encoded.20topic.20name} - # d. narrow/stream/[{stream_id}-]{stream-name}/topic/ + # d. narrow/stream/[{stream_id}-]{stream-name}/subject/ + # {encoded.20topic.20name} + # e. narrow/stream/[{stream_id}-]{stream-name}/topic/ + # {encoded.20topic.20name}/near/{message_id} + # f. narrow/stream/[{stream_id}-]{stream-name}/subject/ # {encoded.20topic.20name}/near/{message_id} fragments = urlparse(link.rstrip("/")).fragment.split("/") len_fragments = len(fragments) @@ -509,7 +546,9 @@ def _parse_narrow_link(cls, link: str) -> ParsedNarrowLink: parsed_link = dict(narrow="stream", stream=stream_data) elif ( - len_fragments == 5 and fragments[1] == "stream" and fragments[3] == "topic" + len_fragments == 5 + and fragments[1] == "stream" + and (fragments[3] == "topic" or fragments[3] == "subject") ): stream_data = cls._decode_stream_data(fragments[2]) topic_name = hash_util_decode(fragments[4]) @@ -527,7 +566,7 @@ def _parse_narrow_link(cls, link: str) -> ParsedNarrowLink: elif ( len_fragments == 7 and fragments[1] == "stream" - and fragments[3] == "topic" + and (fragments[3] == "topic" or fragments[3] == "subject") and fragments[5] == "near" ): stream_data = cls._decode_stream_data(fragments[2]) diff --git a/zulipterminal/ui_tools/messages.py b/zulipterminal/ui_tools/messages.py index 34f8cffbf7..76e76a7880 100644 --- a/zulipterminal/ui_tools/messages.py +++ b/zulipterminal/ui_tools/messages.py @@ -3,7 +3,7 @@ """ import typing -from collections import OrderedDict, defaultdict +from collections import defaultdict from datetime import date, datetime from time import time from typing import Any, Dict, List, NamedTuple, Optional, Tuple, Union @@ -15,19 +15,25 @@ from bs4.element import NavigableString, Tag from tzlocal import get_localzone +from zulipterminal.api_types import Message from zulipterminal.config.keys import is_command_key, primary_key_for_command from zulipterminal.config.symbols import ( + ALL_MESSAGES_MARKER, + DIRECT_MESSAGE_MARKER, + MENTIONED_MESSAGES_MARKER, MESSAGE_CONTENT_MARKER, MESSAGE_HEADER_DIVIDER, QUOTED_TEXT_MARKER, + STARRED_MESSAGES_MARKER, STREAM_TOPIC_SEPARATOR, TIME_MENTION_MARKER, ) -from zulipterminal.config.ui_mappings import STATE_ICON -from zulipterminal.helper import Message, get_unused_fence +from zulipterminal.config.ui_mappings import STATE_ICON, STREAM_ACCESS_TYPE +from zulipterminal.helper import get_unused_fence from zulipterminal.server_url import near_message_url from zulipterminal.ui_tools.tables import render_table from zulipterminal.urwid_types import urwid_MarkupTuple, urwid_Size +from zulipterminal.widget import find_widget_type, process_todo_widget if typing.TYPE_CHECKING: @@ -55,8 +61,8 @@ def __init__(self, message: Message, model: "Model", last_message: Any) -> None: self.topic_name = "" self.email = "" # FIXME: Can we remove this? self.user_id: Optional[int] = None - self.message_links: "OrderedDict[str, Tuple[str, int, bool]]" = OrderedDict() - self.topic_links: "OrderedDict[str, Tuple[str, int, bool]]" = OrderedDict() + self.message_links: Dict[str, Tuple[str, int, bool]] = dict() + self.topic_links: Dict[str, Tuple[str, int, bool]] = dict() self.time_mentions: List[Tuple[str, str]] = list() self.last_message = last_message # if this is the first message @@ -111,7 +117,7 @@ def __init__(self, message: Message, model: "Model", last_message: Any) -> None: def need_recipient_header(self) -> bool: # Prevent redundant information in recipient bar - if len(self.model.narrow) == 1 and self.model.narrow[0][0] == "pm_with": + if len(self.model.narrow) == 1 and self.model.narrow[0][0] == "pm-with": return False if len(self.model.narrow) == 2 and self.model.narrow[1][0] == "topic": return False @@ -152,9 +158,12 @@ def stream_header(self) -> Any: assert self.stream_id is not None color = self.model.stream_dict[self.stream_id]["color"] bar_color = f"s{color}" + stream_access_type = self.model.stream_access_type(self.stream_id) + stream_icon = STREAM_ACCESS_TYPE[stream_access_type]["icon"] stream_title_markup = ( "bar", [ + (bar_color, f" {stream_icon} "), (bar_color, f"{self.stream_name} {STREAM_TOPIC_SEPARATOR} "), ("title", f" {self.topic_name}"), ], @@ -173,7 +182,11 @@ def stream_header(self) -> Any: def private_header(self) -> Any: title_markup = ( "header", - [("general_narrow", "You and "), ("general_narrow", self.recipients_names)], + [ + ("general_narrow", f" {DIRECT_MESSAGE_MARKER} "), + ("general_narrow", "You and "), + ("general_narrow", self.recipients_names), + ], ) title = urwid.Text(title_markup) header = urwid.Columns( @@ -186,11 +199,11 @@ def private_header(self) -> Any: header.markup = title_markup return header - def top_header_bar(self, message_view: Any) -> Any: + def recipient_header(self) -> Any: if self.message["type"] == "stream": - return message_view.stream_header() + return self.stream_header() else: - return message_view.private_header() + return self.private_header() def top_search_bar(self) -> Any: curr_narrow = self.model.narrow @@ -202,34 +215,36 @@ def top_search_bar(self) -> Any: else: self.model.controller.view.search_box.text_box.set_edit_text("") if curr_narrow == []: - text_to_fill = "All messages" + text_to_fill = f" {ALL_MESSAGES_MARKER} All messages " elif len(curr_narrow) == 1 and curr_narrow[0][1] == "private": - text_to_fill = "All direct messages" + text_to_fill = f" {DIRECT_MESSAGE_MARKER} All direct messages " elif len(curr_narrow) == 1 and curr_narrow[0][1] == "starred": - text_to_fill = "Starred messages" + text_to_fill = f" {STARRED_MESSAGES_MARKER} Starred messages " elif len(curr_narrow) == 1 and curr_narrow[0][1] == "mentioned": - text_to_fill = "Mentions" + text_to_fill = f" {MENTIONED_MESSAGES_MARKER} Mentions " elif self.message["type"] == "stream": assert self.stream_id is not None + bar_color = self.model.stream_dict[self.stream_id]["color"] bar_color = f"s{bar_color}" + stream_access_type = self.model.stream_access_type(self.stream_id) + stream_icon = STREAM_ACCESS_TYPE[stream_access_type]["icon"] if len(curr_narrow) == 2 and curr_narrow[1][0] == "topic": text_to_fill = ( "bar", # type: ignore[assignment] - [ - (bar_color, self.stream_name), - (bar_color, ": topic narrow"), - ], + (bar_color, f" {stream_icon} {self.stream_name}: topic narrow "), ) else: text_to_fill = ( "bar", # type: ignore[assignment] - [(bar_color, self.stream_name)], + (bar_color, f" {stream_icon} {self.stream_name} "), ) elif len(curr_narrow) == 1 and len(curr_narrow[0][1].split(",")) > 1: - text_to_fill = "Group direct message conversation" + text_to_fill = ( + f" {DIRECT_MESSAGE_MARKER} Group direct message conversation " + ) else: - text_to_fill = "Direct message conversation" + text_to_fill = f" {DIRECT_MESSAGE_MARKER} Direct message conversation " if is_search_narrow: title_markup = ( @@ -248,17 +263,17 @@ def top_search_bar(self) -> Any: header.markup = title_markup return header - def reactions_view(self, reactions: List[Dict[str, Any]]) -> Any: + def reactions_view( + self, reactions: List[Dict[str, Any]] + ) -> Optional[urwid.Padding]: if not reactions: - return "" + return None try: my_user_id = self.model.user_id reaction_stats = defaultdict(list) for reaction in reactions: - user_id = int(reaction["user"].get("id", -1)) - if user_id == -1: - user_id = int(reaction["user"]["user_id"]) - user_name = reaction["user"]["full_name"] + user_id = self.model.get_user_id_from_reaction(reaction) + user_name = self.model._all_users_by_id[user_id]["full_name"] if user_id == my_user_id: user_name = "You" reaction_stats[reaction["emoji_name"]].append((user_id, user_name)) @@ -293,13 +308,11 @@ def reactions_view(self, reactions: List[Dict[str, Any]]) -> Any: min_width=50, ) except Exception: - return "" + return None - # Use quotes as a workaround for OrderedDict typing issue. - # See https://github.com/python/mypy/issues/6904. @staticmethod def footlinks_view( - message_links: "OrderedDict[str, Tuple[str, int, bool]]", + message_links: Dict[str, Tuple[str, int, bool]], *, maximum_footlinks: int, padded: bool, @@ -357,9 +370,7 @@ def footlinks_view( @classmethod def soup2markup( cls, soup: Any, metadata: Dict[str, Any], **state: Any - ) -> Tuple[ - List[Any], "OrderedDict[str, Tuple[str, int, bool]]", List[Tuple[str, str]] - ]: + ) -> Tuple[List[Any], Dict[str, Tuple[str, int, bool]], List[Tuple[str, str]]]: # Ensure a string is provided, in case the soup finds none # This could occur if eg. an image is removed or not shown markup: List[Union[str, Tuple[Optional[str], Any]]] = [""] @@ -385,7 +396,7 @@ def soup2markup( # if/elif/else chain below for improving legibility. tag = element.name tag_attrs = element.attrs - tag_classes = tag_attrs.get("class", []) + tag_classes: List[str] = element.get_attribute_list("class", []) tag_text = element.text if isinstance(element, NavigableString): @@ -404,7 +415,7 @@ def soup2markup( markup.append(unrendered_template.format(text)) elif tag == "img" and tag_classes == ["emoji"]: # CUSTOM EMOJIS AND ZULIP_EXTRA_EMOJI - emoji_name = tag_attrs.get("title", []) + emoji_name: str = tag_attrs.get("title", "") markup.append(("msg_emoji", f":{emoji_name}:")) elif tag in unrendered_tags: # UNRENDERED SIMPLE TAGS @@ -430,9 +441,10 @@ def soup2markup( markup.append(("msg_math", tag_text)) elif tag == "span" and ( - {"user-group-mention", "user-mention"} & set(tag_classes) + {"user-group-mention", "user-mention", "topic-mention"} + & set(tag_classes) ): - # USER MENTIONS & USER-GROUP MENTIONS + # USER, USER-GROUP & TOPIC MENTIONS markup.append(("msg_mention", tag_text)) elif tag == "a": # LINKS @@ -626,10 +638,7 @@ def soup2markup( def main_view(self) -> List[Any]: # Recipient Header if self.need_recipient_header(): - if self.message["type"] == "stream": - recipient_header = self.stream_header() - else: - recipient_header = self.private_header() + recipient_header = self.recipient_header() else: recipient_header = None @@ -719,6 +728,31 @@ def main_view(self) -> List[Any]: "/me", f"{self.message['sender_full_name']}", 1 ) + if self.message.get("submessages"): + widget_type = find_widget_type(self.message.get("submessages", [])) + + if widget_type == "todo": + title, tasks = process_todo_widget(self.message.get("submessages", [])) + + todo_widget = "To-do\n" + f"{title}" + + if tasks: + for task_id, task_info in tasks.items(): + task_status = "[✔]" if task_info["completed"] else "[ ]" + task_name = task_info["task"] + task_description = task_info["desc"] + + todo_widget += f"\n{task_status} {task_name}" + + if task_description: + todo_widget += f": {task_description}" + + # Update the message content with the latest todo_widget, + # generated from submessages to reflect the current state. + # The original raw content can be fetched if needed, + # though it's not very useful. + self.message["content"] = todo_widget + # Transform raw message content into markup (As needed by urwid.Text) content, self.message_links, self.time_mentions = self.transform_content( self.message["content"], self.model.server_url @@ -774,7 +808,7 @@ def main_view(self) -> List[Any]: (content_header, any_differences), (wrapped_content, True), (footlinks, footlinks is not None), - (reactions, reactions != ""), + (reactions, reactions is not None), ] self.header = [part for part, condition in parts[:2] if condition] @@ -807,7 +841,7 @@ def transform_content( cls, content: Any, server_url: str ) -> Tuple[ Tuple[None, Any], - "OrderedDict[str, Tuple[str, int, bool]]", + Dict[str, Tuple[str, int, bool]], List[Tuple[str, str]], ]: soup = BeautifulSoup(content, "lxml") @@ -815,11 +849,11 @@ def transform_content( metadata = dict( server_url=server_url, - message_links=OrderedDict(), + message_links=dict(), time_mentions=list(), ) # type: Dict[str, Any] - if body and body.find(name="blockquote"): + if isinstance(body, Tag) and body.find(name="blockquote"): metadata["bq_len"] = cls.indent_quoted_content(soup, QUOTED_TEXT_MARKER) markup, message_links, time_mentions = cls.soup2markup(body, metadata) @@ -888,7 +922,7 @@ def mouse_event( if event == "mouse press" and button == 1: if self.model.controller.is_in_editor_mode(): return True - self.keypress(size, primary_key_for_command("ENTER")) + self.keypress(size, primary_key_for_command("ACTIVATE_BUTTON")) return True return super().mouse_event(size, event, button, col, row, focus) @@ -927,7 +961,7 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: elif is_command_key("TOGGLE_NARROW", key): self.model.unset_search_narrow() if self.message["type"] == "private": - if len(self.model.narrow) == 1 and self.model.narrow[0][0] == "pm_with": + if len(self.model.narrow) == 1 and self.model.narrow[0][0] == "pm-with": self.model.controller.narrow_to_all_pm( contextual_message_id=self.message["id"], ) @@ -1035,12 +1069,12 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: # the time limit. A limit of 0 signifies no limit # on message body editing. msg_body_edit_enabled = True - if self.model.initial_data["realm_message_content_edit_limit_seconds"] > 0: + edit_time_limit = self.model.initial_data[ + "realm_message_content_edit_limit_seconds" + ] + if edit_time_limit is not None and edit_time_limit > 0: if self.message["sender_id"] == self.model.user_id: time_since_msg_sent = time() - self.message["timestamp"] - edit_time_limit = self.model.initial_data[ - "realm_message_content_edit_limit_seconds" - ] # Don't allow editing message body if time-limit exceeded. if time_since_msg_sent >= edit_time_limit: if self.message["type"] == "private": @@ -1115,4 +1149,6 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: ) elif is_command_key("ADD_REACTION", key): self.model.controller.show_emoji_picker(self.message) + elif is_command_key("MSG_SENDER_INFO", key): + self.model.controller.show_msg_sender_info(self.message["sender_id"]) return key diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index 8f6ad15de4..02b3afbd0b 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -3,7 +3,6 @@ """ import threading -from collections import OrderedDict from datetime import datetime from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union @@ -11,12 +10,13 @@ import urwid from typing_extensions import Literal -from zulipterminal.api_types import EditPropagateMode +from zulipterminal.api_types import EditPropagateMode, Message from zulipterminal.config.keys import ( HELP_CATEGORIES, KEY_BINDINGS, + display_key_for_urwid_key, + display_keys_for_command, is_command_key, - keys_for_command, primary_key_for_command, ) from zulipterminal.config.markdown_examples import MARKDOWN_ELEMENTS @@ -24,6 +24,7 @@ CHECK_MARK, COLUMN_TITLE_BAR_LINE, PINNED_STREAMS_DIVIDER, + SECTION_DIVIDER_LINE, ) from zulipterminal.config.ui_mappings import ( BOT_TYPE_BY_ID, @@ -35,13 +36,13 @@ ) from zulipterminal.config.ui_sizes import LEFT_WIDTH from zulipterminal.helper import ( - Message, TidiedUserInfo, asynch, match_emoji, match_stream, match_user, ) +from zulipterminal.platform_code import PLATFORM, detected_python_in_full from zulipterminal.server_url import near_message_url from zulipterminal.ui_tools.boxes import PanelSearchBox from zulipterminal.ui_tools.buttons import ( @@ -65,17 +66,21 @@ class ModListWalker(urwid.SimpleFocusListWalker): + def __init__(self, *, contents: List[Any], action: Callable[[], None]) -> None: + self._action = action + super().__init__(contents) + def set_focus(self, position: int) -> None: # When setting focus via set_focus method. self.focus = position self._modified() - if hasattr(self, "read_message"): - self.read_message() + + self._action() def _set_focus(self, index: int) -> None: # This method is called when directly setting focus via # self.focus = focus_position - if not self: + if not self: # type: ignore[truthy-bool] # Implemented in base class self._focus = 0 return if index < 0 or index >= len(self): @@ -86,8 +91,8 @@ def _set_focus(self, index: int) -> None: if index != self._focus: self._focus_changed(index) self._focus = index - if hasattr(self, "read_message"): - self.read_message() + + self._action() def extend(self, items: List[Any], focus_position: Optional[int] = None) -> int: if focus_position is None: @@ -107,8 +112,7 @@ def __init__(self, model: Any, view: Any) -> None: self.view = view # Initialize for reference self.focus_msg = 0 - self.log = ModListWalker(self.main_view()) - self.log.read_message = self.read_message + self.log = ModListWalker(contents=self.main_view(), action=self.read_message) super().__init__(self.log) self.set_focus(self.focus_msg) @@ -119,7 +123,7 @@ def __init__(self, model: Any, view: Any) -> None: def main_view(self) -> List[Any]: msg_btn_list = create_msg_box_list(self.model) focus_msg = self.model.get_focus_in_current_narrow() - if focus_msg == set(): + if focus_msg is None: focus_msg = len(msg_btn_list) - 1 self.focus_msg = focus_msg return msg_btn_list @@ -129,7 +133,7 @@ def load_old_messages(self, anchor: int) -> None: self.old_loading = True ids_to_keep = self.model.get_message_ids_in_current_narrow() - if self.log: + if self.log: # type: ignore[truthy-bool] # Implemented in base class top_message_id = self.log[0].original_widget.message["id"] ids_to_keep.remove(top_message_id) # update this id no_update_baseline = {top_message_id} @@ -141,7 +145,7 @@ def load_old_messages(self, anchor: int) -> None: # Only update if more messages are provided if ids_to_process != no_update_baseline: - if self.log: + if self.log: # type: ignore[truthy-bool] # Implemented in base class self.log.remove(self.log[0]) # avoid duplication when updating message_list = create_msg_box_list(self.model, ids_to_process) @@ -161,7 +165,7 @@ def load_new_messages(self, anchor: int) -> None: current_ids = self.model.get_message_ids_in_current_narrow() self.model.get_messages(num_before=0, num_after=30, anchor=anchor) new_ids = self.model.get_message_ids_in_current_narrow() - current_ids - if self.log: + if self.log: # type: ignore[truthy-bool] # Implemented in base class last_message = self.log[-1].original_widget.message else: last_message = None @@ -234,6 +238,14 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: message = self.focus.original_widget.message self.model.toggle_message_star_status(message) + elif is_command_key("REACTION_AGREEMENT", key) and self.focus is not None: + message = self.focus.original_widget.message + message_reactions = message["reactions"] + if message_reactions: + self.model.toggle_message_reaction( + message, message_reactions[0]["emoji_name"] + ) + key = super().keypress(size, key) return key @@ -242,7 +254,7 @@ def update_search_box_narrow(self, message_view: Any) -> None: return # if view is ready display current narrow # at the bottom of the view. - recipient_bar = message_view.top_header_bar(message_view) + recipient_bar = message_view.recipient_header() top_header = message_view.top_search_bar() self.model.controller.view.search_box.conversation_focus.set_text( top_header.markup @@ -317,16 +329,8 @@ def __init__(self, streams_btn_list: List[Any], view: Any) -> None: ) super().__init__( list_box, - header=urwid.LineBox( - self.stream_search_box, - tlcorner="─", - tline="", - lline="", - trcorner="─", - blcorner="─", - rline="", - bline="─", - brcorner="─", + header=urwid.Pile( + [self.stream_search_box, urwid.Divider(SECTION_DIVIDER_LINE)] ), ) self.search_lock = threading.Lock() @@ -393,7 +397,7 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: self.stream_search_box.set_caption(" ") self.view.controller.enter_editor_mode_with(self.stream_search_box) return key - elif is_command_key("GO_BACK", key): + elif is_command_key("CLEAR_SEARCH", key): self.stream_search_box.reset_search_text() self.log.clear() self.log.extend(self.streams_btn_list) @@ -418,25 +422,31 @@ def __init__( self, "SEARCH_TOPICS", self.update_topics ) self.header_list = urwid.Pile( - [self.stream_button, urwid.Divider("─"), self.topic_search_box] + [ + self.stream_button, + urwid.Divider(SECTION_DIVIDER_LINE), + self.topic_search_box, + urwid.Divider(SECTION_DIVIDER_LINE), + ] ) + self.list_box.focus_position = self._focus_position_for_topic_name() super().__init__( self.list_box, - header=urwid.LineBox( - self.header_list, - tlcorner="─", - tline="", - lline="", - trcorner="─", - blcorner="─", - rline="", - bline="─", - brcorner="─", - ), + header=self.header_list, ) self.search_lock = threading.Lock() self.empty_search = False + def _focus_position_for_topic_name(self) -> int: + saved_topic_state = self.view.saved_topic_in_stream_id( + self.stream_button.stream_id + ) + if saved_topic_state is not None: + for index, topic in enumerate(self.log): + if topic.topic_name == saved_topic_state: + return index + return 0 + @asynch def update_topics(self, search_box: Any, new_text: str) -> None: if not self.view.controller.is_in_editor_mode(): @@ -507,7 +517,7 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: self.topic_search_box.set_caption(" ") self.view.controller.enter_editor_mode_with(self.topic_search_box) return key - elif is_command_key("GO_BACK", key): + elif is_command_key("CLEAR_SEARCH", key): self.topic_search_box.reset_search_text() self.log.clear() self.log.extend(self.topics_btn_list) @@ -547,26 +557,10 @@ def __init__(self, view: Any, model: Any, write_box: Any, search_box: Any) -> No self.model = model self.controller = model.controller self.view = view - self.last_unread_pm = None self.search_box = search_box view.message_view = message_view super().__init__(message_view, header=search_box, footer=write_box) - def get_next_unread_pm(self) -> Optional[int]: - pms = list(self.model.unread_counts["unread_pms"].keys()) - next_pm = False - for pm in pms: - if next_pm is True: - self.last_unread_pm = pm - return pm - if pm == self.last_unread_pm: - next_pm = True - if len(pms) > 0: - pm = pms[0] - self.last_unread_pm = pm - return pm - return None - def update_message_list_status_markers(self) -> None: for message_w in self.body.log: message_box = message_w.original_widget @@ -597,7 +591,10 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: if self.footer.focus is None: stream_id = self.model.stream_id stream_dict = self.model.stream_dict - self.footer.stream_box_view(caption=stream_dict[stream_id]["name"]) + if stream_id is None: + self.footer.stream_box_view(0) + else: + self.footer.stream_box_view(caption=stream_dict[stream_id]["name"]) self.set_focus("footer") self.footer.focus_position = 0 return key @@ -611,9 +608,20 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: elif is_command_key("NEXT_UNREAD_TOPIC", key): # narrow to next unread topic - stream_topic = self.model.get_next_unread_topic() - if stream_topic is None: + focus = self.view.message_view.focus + narrow = self.model.narrow + if focus: + current_msg_id = focus.original_widget.message["id"] + stream_topic = self.model.next_unread_topic_from_message_id( + current_msg_id + ) + if stream_topic is None: + return key + elif narrow[0][0] == "stream" and narrow[1][0] == "topic": + stream_topic = self.model.next_unread_topic_from_message_id(None) + else: return key + stream_id, topic = stream_topic self.controller.narrow_to_topic( stream_name=self.model.stream_dict[stream_id]["name"], @@ -622,7 +630,7 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: return key elif is_command_key("NEXT_UNREAD_PM", key): # narrow to next unread pm - pm = self.get_next_unread_pm() + pm = self.model.get_next_unread_pm() if pm is None: return key email = self.model.user_id_email_dict[pm] @@ -652,17 +660,8 @@ def __init__(self, view: Any) -> None: self.view = view self.user_search = PanelSearchBox(self, "SEARCH_PEOPLE", self.update_user_list) self.view.user_search = self.user_search - search_box = urwid.LineBox( - self.user_search, - tlcorner="─", - tline="", - lline="", - trcorner="─", - blcorner="─", - rline="", - bline="─", - brcorner="─", - ) + search_box = urwid.Pile([self.user_search, urwid.Divider(SECTION_DIVIDER_LINE)]) + self.allow_update_user_list = True self.search_lock = threading.Lock() self.empty_search = False @@ -682,7 +681,7 @@ def update_user_list( user_list is not None and search_box is None and new_text is None ) # _start_presence_updates. - # Return if the method is called by PanelSearchBox (urwid.Edit) while + # Return if the method is called by PanelSearchBox (ReadlineEdit) while # the search is inactive and user_list is None. # NOTE: The additional not user_list check is to not false trap # _start_presence_updates but allow it to update the user list. @@ -759,7 +758,7 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: self.user_search.set_caption(" ") self.view.controller.enter_editor_mode_with(self.user_search) return key - elif is_command_key("GO_BACK", key): + elif is_command_key("CLEAR_SEARCH", key): self.user_search.reset_search_text() self.allow_update_user_list = True self.body = UsersView(self.view.controller, self.users_btn_list) @@ -856,7 +855,7 @@ def streams_view(self) -> Any: rline="", lline="", bline="", - brcorner="─", + brcorner="", ) return w @@ -888,7 +887,7 @@ def topics_view(self, stream_button: Any) -> Any: rline="", lline="", bline="", - brcorner="─", + brcorner="", ) return w @@ -1058,7 +1057,7 @@ def make_table_with_categories( return widgets def keypress(self, size: urwid_Size, key: str) -> str: - if is_command_key("GO_BACK", key) or is_command_key(self.command, key): + if is_command_key("EXIT_POPUP", key) or is_command_key(self.command, key): self.controller.exit_popup() return super().keypress(size, key) @@ -1073,7 +1072,25 @@ def __init__( urwid.Padding(urwid.Text(notice_text), left=1, right=1), urwid.Divider(), ] - super().__init__(controller, widgets, "GO_BACK", width, title) + super().__init__(controller, widgets, "EXIT_POPUP", width, title) + + +class ExceptionView(NoticeView): + def __init__( + self, + controller: Any, + notice_text: Any, + width: int, + title: str, + traceback: str, + ) -> None: + self.traceback = traceback + super().__init__(controller, notice_text, width, title) + + def keypress(self, size: urwid_Size, key: str) -> str: + if is_command_key("COPY_TRACEBACK", key): + self.controller.copy_to_clipboard(self.traceback, "Traceback") + return super().keypress(size, key) class AboutView(PopUpView): @@ -1084,18 +1101,21 @@ def __init__( *, zt_version: str, server_version: str, - server_feature_level: Optional[int], + server_feature_level: int, theme_name: str, color_depth: int, autohide_enabled: bool, maximum_footlinks: int, notify_enabled: bool, + exit_confirmation_enabled: bool, + transparency_enabled: bool, ) -> None: self.feature_level_content = ( [("Feature level", str(server_feature_level))] if server_feature_level else [] ) + contents = [ ("Application", [("Zulip Terminal", zt_version)]), ("Server", [("Version", server_version)] + self.feature_level_content), @@ -1107,43 +1127,79 @@ def __init__( ("Maximum footlinks", str(maximum_footlinks)), ("Color depth", str(color_depth)), ("Notifications", "enabled" if notify_enabled else "disabled"), + ( + "Exit confirmation", + "enabled" if exit_confirmation_enabled else "disabled", + ), + ("Transparency", "enabled" if transparency_enabled else "disabled"), ], ), + ( + "Detected Environment", + [("Platform", PLATFORM), ("Python", detected_python_in_full())], + ), ] + # Prepare string version of contents to support copying to clipboard + sections = [] + for section_title, properties in contents: + formatted_properties = "\n".join( + f"{label}: {value}" for label, value in properties + ) + sections.append(f"#### {section_title}\n{formatted_properties}") + self.copy_info = "\n\n".join(sections) + + about_keys = "[" + ", ".join(display_keys_for_command("COPY_ABOUT_INFO")) + "]" + contents.append((f"Copy information to clipboard {about_keys}", [])) + popup_width, column_widths = self.calculate_table_widths(contents, len(title)) widgets = self.make_table_with_categories(contents, column_widths) super().__init__(controller, widgets, "ABOUT", popup_width, title) + def keypress(self, size: urwid_Size, key: str) -> str: + if is_command_key("COPY_ABOUT_INFO", key): + self.controller.copy_to_clipboard(self.copy_info, "About info") + return super().keypress(size, key) + class UserInfoView(PopUpView): - def __init__(self, controller: Any, user_id: int, title: str) -> None: - display_data = self._fetch_user_data(controller, user_id) + def __init__(self, controller: Any, user_id: int, title: str, command: str) -> None: + display_data, display_custom_profile_data = self._fetch_user_data( + controller, user_id + ) user_details = [ (key, value) for key, value in display_data.items() if key != "Name" ] user_view_content = [(display_data["Name"], user_details)] + if display_custom_profile_data: + user_view_content.extend( + [("Additional Details", list(display_custom_profile_data.items()))] + ) + popup_width, column_widths = self.calculate_table_widths( user_view_content, len(title) ) widgets = self.make_table_with_categories(user_view_content, column_widths) - super().__init__(controller, widgets, "USER_INFO", popup_width, title) + super().__init__(controller, widgets, command, popup_width, title) @staticmethod - def _fetch_user_data(controller: Any, user_id: int) -> Dict[str, Any]: + def _fetch_user_data( + controller: Any, user_id: int + ) -> Tuple[Dict[str, str], Dict[str, str]]: # Get user data from model - data: TidiedUserInfo = controller.model.get_user_info(user_id) + data: Optional[TidiedUserInfo] = controller.model.get_user_info(user_id) + display_custom_profile_data: Dict[str, str] = {} if not data: display_data = { "Name": "(Unavailable)", "Error": "User data not found", } - return display_data + return (display_data, display_custom_profile_data) # Style the data obtained to make it displayable display_data = {"Name": data["full_name"]} @@ -1179,7 +1235,23 @@ def _fetch_user_data(controller: Any, user_id: int) -> Dict[str, Any]: if data["last_active"]: display_data["Last active"] = data["last_active"] - return display_data + # This will be an empty dict in case of bot users + custom_profile_data = data["custom_profile_data"] + + if custom_profile_data: + for field in custom_profile_data: + if field["type"] == 6: # Person picker + user_names = [ + controller.model.user_name_from_id(user) + for user in field["value"] + ] + field["value"] = ", ".join(user_names) + # After conversion of field type 6, all values are str + assert isinstance(field["value"], str) + + display_custom_profile_data[field["label"]] = field["value"] + + return (display_data, display_custom_profile_data) class HelpView(PopUpView): @@ -1191,9 +1263,14 @@ def __init__(self, controller: Any, title: str) -> None: for binding in KEY_BINDINGS.values() if binding["key_category"] == category ) - key_bindings = [] - for binding in keys_in_category: - key_bindings.append((binding["help_text"], ", ".join(binding["keys"]))) + key_bindings = [ + ( + binding["help_text"], + ", ".join(map(display_key_for_urwid_key, binding["keys"])), + ) + for binding in keys_in_category + ] + help_menu_content.append((HELP_CATEGORIES[category], key_bindings)) popup_width, column_widths = self.calculate_table_widths( @@ -1293,7 +1370,7 @@ def exit_popup_no(self, args: Any) -> None: self.controller.exit_popup() def keypress(self, size: urwid_Size, key: str) -> str: - if is_command_key("GO_BACK", key): + if is_command_key("EXIT_POPUP", key): self.controller.exit_popup() return super().keypress(size, key) @@ -1345,9 +1422,16 @@ def __init__(self, controller: Any, stream_id: int) -> None: if stream["history_public_to_subscribers"] else "Not Public to Users" ) - member_keys = ", ".join(map(repr, keys_for_command("STREAM_MEMBERS"))) - self.stream_email = stream["email_address"] - email_keys = ", ".join(map(repr, keys_for_command("COPY_STREAM_EMAIL"))) + member_keys = ", ".join(map(repr, display_keys_for_command("STREAM_MEMBERS"))) + + self._stream_email = controller.model.get_stream_email_address(stream_id) + if self._stream_email is None: + stream_copy_text = "< Stream email is unavailable >" + else: + email_keys = ", ".join( + map(repr, display_keys_for_command("COPY_STREAM_EMAIL")) + ) + stream_copy_text = f"Press {email_keys} to copy Stream email address" weekly_traffic = stream["stream_weekly_traffic"] weekly_msg_count = ( @@ -1362,7 +1446,8 @@ def __init__(self, controller: Any, stream_id: int) -> None: ) desc = urwid.Text(self.markup_desc) - stream_info_content = [ + # NOTE: This is treated as a member to make it easier to test + self._stream_info_content = [ ( "Stream Details", [ @@ -1377,10 +1462,7 @@ def __init__(self, controller: Any, stream_id: int) -> None: "Stream Members", f"{total_members} (Press {member_keys} to view list)", ), - ( - "Stream email", - f"Press {email_keys} to copy Stream email address", - ), + ("Stream email", stream_copy_text), ("History of Stream", f"{availability_of_history}"), ("Posting Policy", f"{stream_policy}"), ], @@ -1389,7 +1471,7 @@ def __init__(self, controller: Any, stream_id: int) -> None: ] # type: PopUpViewTableContent popup_width, column_widths = self.calculate_table_widths( - stream_info_content, len(title) + self._stream_info_content, len(title) ) muted_setting = urwid.CheckBox( @@ -1442,7 +1524,7 @@ def __init__(self, controller: Any, stream_id: int) -> None: ), ] self.widgets = self.make_table_with_categories( - stream_info_content, column_widths + self._stream_info_content, column_widths ) # Stream description. @@ -1470,8 +1552,10 @@ def toggle_visual_notification(self, button: Any, new_state: bool) -> None: def keypress(self, size: urwid_Size, key: str) -> str: if is_command_key("STREAM_MEMBERS", key): self.controller.show_stream_members(stream_id=self.stream_id) - elif is_command_key("COPY_STREAM_EMAIL", key): - self.controller.copy_to_clipboard(self.stream_email, "Stream email") + elif ( + is_command_key("COPY_STREAM_EMAIL", key) and self._stream_email is not None + ): + self.controller.copy_to_clipboard(self._stream_email, "Stream email") return super().keypress(size, key) @@ -1496,7 +1580,7 @@ def __init__(self, controller: Any, stream_id: int) -> None: super().__init__(controller, widgets, "STREAM_INFO", popup_width, title) def keypress(self, size: urwid_Size, key: str) -> str: - if is_command_key("GO_BACK", key) or is_command_key("STREAM_MEMBERS", key): + if is_command_key("EXIT_POPUP", key) or is_command_key("STREAM_MEMBERS", key): self.controller.show_stream_info(stream_id=self.stream_id) return key return super().keypress(size, key) @@ -1508,8 +1592,8 @@ def __init__( controller: Any, msg: Message, title: str, - topic_links: "OrderedDict[str, Tuple[str, int, bool]]", - message_links: "OrderedDict[str, Tuple[str, int, bool]]", + topic_links: Dict[str, Tuple[str, int, bool]], + message_links: Dict[str, Tuple[str, int, bool]], time_mentions: List[Tuple[str, str]], ) -> None: self.msg = msg @@ -1521,14 +1605,14 @@ def __init__( msg["timestamp"], show_seconds=True, show_year=True ) view_in_browser_keys = "[{}]".format( - ", ".join(map(str, keys_for_command("VIEW_IN_BROWSER"))) + ", ".join(map(str, display_keys_for_command("VIEW_IN_BROWSER"))) ) full_rendered_message_keys = "[{}]".format( - ", ".join(map(str, keys_for_command("FULL_RENDERED_MESSAGE"))) + ", ".join(map(str, display_keys_for_command("FULL_RENDERED_MESSAGE"))) ) full_raw_message_keys = "[{}]".format( - ", ".join(map(str, keys_for_command("FULL_RAW_MESSAGE"))) + ", ".join(map(str, display_keys_for_command("FULL_RAW_MESSAGE"))) ) msg_info = [ ( @@ -1560,7 +1644,9 @@ def __init__( if self.show_edit_history_label: msg_info[0][1][0] = ("Date & Time (Original)", date_and_time) - keys = "[{}]".format(", ".join(map(str, keys_for_command("EDIT_HISTORY")))) + keys = "[{}]".format( + ", ".join(map(str, display_keys_for_command("EDIT_HISTORY"))) + ) msg_info[1][1].append(("Edit History", keys)) # Render the category using the existing table methods if links exist. if message_links: @@ -1571,7 +1657,12 @@ def __init__( msg_info.append(("Time mentions", time_mentions)) if msg["reactions"]: reactions = sorted( - (reaction["emoji_name"], reaction["user"]["full_name"]) + ( + reaction["emoji_name"], + controller.model._all_users_by_id[ + controller.model.get_user_id_from_reaction(reaction) + ]["full_name"], + ) for reaction in msg["reactions"] ) grouped_reactions: Dict[str, str] = dict() @@ -1626,7 +1717,7 @@ def __init__( @staticmethod def create_link_buttons( - controller: Any, links: "OrderedDict[str, Tuple[str, int, bool]]" + controller: Any, links: Dict[str, Tuple[str, int, bool]] ) -> Tuple[List[MessageLinkButton], int]: link_widgets = [] link_width = 0 @@ -1689,7 +1780,11 @@ def __init__(self, controller: Any, button: Any) -> None: for mode in EDIT_MODE_CAPTIONS: self.add_radio_button(mode) super().__init__( - controller, self.widgets, "ENTER", 62, "Topic edit propagation mode" + controller, + self.widgets, + "ACTIVATE_BUTTON", + 62, + "Topic edit propagation mode", ) # Set cursor to marked checkbox. for i in range(len(self.widgets)): @@ -1722,8 +1817,8 @@ def __init__( self, controller: Any, message: Message, - topic_links: "OrderedDict[str, Tuple[str, int, bool]]", - message_links: "OrderedDict[str, Tuple[str, int, bool]]", + topic_links: Dict[str, Tuple[str, int, bool]], + message_links: Dict[str, Tuple[str, int, bool]], time_mentions: List[Tuple[str, str]], title: str, ) -> None: @@ -1824,7 +1919,7 @@ def _get_author_prefix(snapshot: Dict[str, Any], tag: EditHistoryTag) -> str: return author_prefix def keypress(self, size: urwid_Size, key: str) -> str: - if is_command_key("GO_BACK", key) or is_command_key("EDIT_HISTORY", key): + if is_command_key("EXIT_POPUP", key) or is_command_key("EDIT_HISTORY", key): self.controller.show_msg_info( msg=self.message, topic_links=self.topic_links, @@ -1840,8 +1935,8 @@ def __init__( self, controller: Any, message: Message, - topic_links: "OrderedDict[str, Tuple[str, int, bool]]", - message_links: "OrderedDict[str, Tuple[str, int, bool]]", + topic_links: Dict[str, Tuple[str, int, bool]], + message_links: Dict[str, Tuple[str, int, bool]], time_mentions: List[Tuple[str, str]], title: str, ) -> None: @@ -1866,7 +1961,7 @@ def __init__( ) def keypress(self, size: urwid_Size, key: str) -> str: - if is_command_key("GO_BACK", key) or is_command_key( + if is_command_key("EXIT_POPUP", key) or is_command_key( "FULL_RENDERED_MESSAGE", key ): self.controller.show_msg_info( @@ -1884,8 +1979,8 @@ def __init__( self, controller: Any, message: Message, - topic_links: "OrderedDict[str, Tuple[str, int, bool]]", - message_links: "OrderedDict[str, Tuple[str, int, bool]]", + topic_links: Dict[str, Tuple[str, int, bool]], + message_links: Dict[str, Tuple[str, int, bool]], time_mentions: List[Tuple[str, str]], title: str, ) -> None: @@ -1918,7 +2013,7 @@ def __init__( ) def keypress(self, size: urwid_Size, key: str) -> str: - if is_command_key("GO_BACK", key) or is_command_key("FULL_RAW_MESSAGE", key): + if is_command_key("EXIT_POPUP", key) or is_command_key("FULL_RAW_MESSAGE", key): self.controller.show_msg_info( msg=self.message, topic_links=self.topic_links, @@ -1953,16 +2048,8 @@ def __init__( self.emoji_search = PanelSearchBox( self, "SEARCH_EMOJIS", self.update_emoji_list ) - search_box = urwid.LineBox( - self.emoji_search, - tlcorner="─", - tline="", - lline="", - trcorner="─", - blcorner="─", - rline="", - bline="─", - brcorner="─", + search_box = urwid.Pile( + [self.emoji_search, urwid.Divider(SECTION_DIVIDER_LINE)] ) self.empty_search = False self.search_lock = threading.Lock() @@ -2081,7 +2168,7 @@ def keypress(self, size: urwid_Size, key: str) -> str: self.emoji_search.set_caption(" ") self.controller.enter_editor_mode_with(self.emoji_search) return key - elif is_command_key("GO_BACK", key) or is_command_key("ADD_REACTION", key): + elif is_command_key("EXIT_POPUP", key) or is_command_key("ADD_REACTION", key): for emoji_code, emoji_name in self.selected_emojis.items(): self.controller.model.toggle_message_reaction(self.message, emoji_name) self.emoji_search.reset_search_text() diff --git a/zulipterminal/unicode_emojis.py b/zulipterminal/unicode_emojis.py index d4d449976b..4daac0bb8c 100644 --- a/zulipterminal/unicode_emojis.py +++ b/zulipterminal/unicode_emojis.py @@ -4,1841 +4,1836 @@ # Ignore long lines # ruff: noqa: E501 -from collections import OrderedDict - - # fmt: off # Generated automatically by tools/convert-unicode-emoji-data # Do not modify. -EMOJI_DATA = OrderedDict( - [ - ("+1", {'code': '1f44d', 'aliases': ['thumbs_up', 'like']}), - ("-1", {'code': '1f44e', 'aliases': ['thumbs_down']}), - ("100", {'code': '1f4af', 'aliases': ['hundred']}), - ("1234", {'code': '1f522', 'aliases': ['numbers']}), - ("a", {'code': '1f170', 'aliases': []}), - ("ab", {'code': '1f18e', 'aliases': []}), - ("abacus", {'code': '1f9ee', 'aliases': ['calculation']}), - ("abc", {'code': '1f524', 'aliases': []}), - ("abcd", {'code': '1f521', 'aliases': ['alphabet']}), - ("accessible", {'code': '267f', 'aliases': ['wheelchair', 'disabled']}), - ("accordion", {'code': '1fa97', 'aliases': ['concertina', 'squeeze_box']}), - ("action", {'code': '1f3ac', 'aliases': []}), - ("adhesive_bandage", {'code': '1fa79', 'aliases': ['bandage']}), - ("aerial_tramway", {'code': '1f6a1', 'aliases': ['ski_lift']}), - ("airplane", {'code': '2708', 'aliases': []}), - ("alarm_clock", {'code': '23f0', 'aliases': []}), - ("alchemy", {'code': '2697', 'aliases': ['alembic']}), - ("alien", {'code': '1f47d', 'aliases': ['ufo']}), - ("ambulance", {'code': '1f691', 'aliases': []}), - ("american_football", {'code': '1f3c8', 'aliases': []}), - ("anatomical_heart", {'code': '1fac0', 'aliases': ['anatomical', 'cardiology', 'pulse']}), - ("anchor", {'code': '2693', 'aliases': []}), - ("angel", {'code': '1f47c', 'aliases': []}), - ("anger", {'code': '1f4a2', 'aliases': ['bam', 'pow']}), - ("anger_bubble", {'code': '1f5ef', 'aliases': []}), - ("angry", {'code': '1f620', 'aliases': []}), - ("angry_cat", {'code': '1f63e', 'aliases': ['pouting_cat']}), - ("anguish", {'code': '1f62b', 'aliases': []}), - ("anguished", {'code': '1f627', 'aliases': ['pained']}), - ("ant", {'code': '1f41c', 'aliases': []}), - ("apple", {'code': '1f34e', 'aliases': []}), - ("aquarius", {'code': '2652', 'aliases': []}), - ("arabian_camel", {'code': '1f42a', 'aliases': []}), - ("archive", {'code': '1f5c3', 'aliases': []}), - ("aries", {'code': '2648', 'aliases': []}), - ("art", {'code': '1f3a8', 'aliases': ['palette', 'painting']}), - ("artist", {'code': '1f9d1-200d-1f3a8', 'aliases': []}), - ("asterisk", {'code': '002a-20e3', 'aliases': []}), - ("astonished", {'code': '1f632', 'aliases': []}), - ("astronaut", {'code': '1f9d1-200d-1f680', 'aliases': []}), - ("at_work", {'code': '2692', 'aliases': ['hammer_and_pick']}), - ("athletic_shoe", {'code': '1f45f', 'aliases': ['sneaker', 'running_shoe']}), - ("atm", {'code': '1f3e7', 'aliases': []}), - ("atom", {'code': '269b', 'aliases': ['physics']}), - ("auto_rickshaw", {'code': '1f6fa', 'aliases': ['tuk_tuk']}), - ("avocado", {'code': '1f951', 'aliases': []}), - ("axe", {'code': '1fa93', 'aliases': ['hatchet', 'split']}), - ("b", {'code': '1f171', 'aliases': []}), - ("baby", {'code': '1f476', 'aliases': []}), - ("baby_bottle", {'code': '1f37c', 'aliases': []}), - ("baby_change_station", {'code': '1f6bc', 'aliases': ['nursery']}), - ("back", {'code': '1f519', 'aliases': []}), - ("backpack", {'code': '1f392', 'aliases': ['satchel']}), - ("bacon", {'code': '1f953', 'aliases': []}), - ("badger", {'code': '1f9a1', 'aliases': ['honey_badger', 'pester']}), - ("badminton", {'code': '1f3f8', 'aliases': []}), - ("bagel", {'code': '1f96f', 'aliases': ['schmear']}), - ("baggage_claim", {'code': '1f6c4', 'aliases': []}), - ("baguette", {'code': '1f956', 'aliases': []}), - ("ball", {'code': '26f9', 'aliases': ['sports']}), - ("ballet_shoes", {'code': '1fa70', 'aliases': ['ballet']}), - ("balloon", {'code': '1f388', 'aliases': ['celebration']}), - ("ballot_box", {'code': '1f5f3', 'aliases': []}), - ("bamboo", {'code': '1f38d', 'aliases': []}), - ("banana", {'code': '1f34c', 'aliases': []}), - ("bangbang", {'code': '203c', 'aliases': ['double_exclamation']}), - ("banjo", {'code': '1fa95', 'aliases': ['stringed']}), - ("bank", {'code': '1f3e6', 'aliases': []}), - ("bar_chart", {'code': '1f4ca', 'aliases': []}), - ("barber", {'code': '1f488', 'aliases': ['striped_pole']}), - ("baseball", {'code': '26be', 'aliases': []}), - ("basket", {'code': '1f9fa', 'aliases': ['farming', 'laundry', 'picnic']}), - ("basketball", {'code': '1f3c0', 'aliases': []}), - ("bat", {'code': '1f987', 'aliases': []}), - ("bath", {'code': '1f6c0', 'aliases': []}), - ("bathtub", {'code': '1f6c1', 'aliases': []}), - ("battery", {'code': '1f50b', 'aliases': ['full_battery']}), - ("beach", {'code': '1f3d6', 'aliases': []}), - ("beach_umbrella", {'code': '26f1', 'aliases': []}), - ("beans", {'code': '1fad8', 'aliases': ['kidney', 'legume']}), - ("bear", {'code': '1f43b', 'aliases': []}), - ("beaver", {'code': '1f9ab', 'aliases': ['dam']}), - ("bed", {'code': '1f6cf', 'aliases': ['bedroom']}), - ("bee", {'code': '1f41d', 'aliases': ['buzz', 'honeybee']}), - ("beer", {'code': '1f37a', 'aliases': []}), - ("beers", {'code': '1f37b', 'aliases': []}), - ("beetle", {'code': '1fab2', 'aliases': []}), - ("beginner", {'code': '1f530', 'aliases': []}), - ("bell_pepper", {'code': '1fad1', 'aliases': ['capsicum', 'pepper', 'vegetable']}), - ("bellhop_bell", {'code': '1f6ce', 'aliases': ['reception', 'services', 'ding']}), - ("bento", {'code': '1f371', 'aliases': []}), - ("beverage_box", {'code': '1f9c3', 'aliases': ['beverage', 'box', 'straw']}), - ("big_smile", {'code': '1f604', 'aliases': []}), - ("bike", {'code': '1f6b2', 'aliases': ['bicycle']}), - ("bikini", {'code': '1f459', 'aliases': []}), - ("billed_cap", {'code': '1f9e2', 'aliases': ['baseball_cap']}), - ("billiards", {'code': '1f3b1', 'aliases': ['pool', '8_ball']}), - ("biohazard", {'code': '2623', 'aliases': []}), - ("bird", {'code': '1f426', 'aliases': []}), - ("birthday", {'code': '1f382', 'aliases': []}), - ("bison", {'code': '1f9ac', 'aliases': ['buffalo', 'herd', 'wisent']}), - ("biting_lip", {'code': '1fae6', 'aliases': ['flirting', 'uncomfortable']}), - ("black_and_white_square", {'code': '1f533', 'aliases': []}), - ("black_belt", {'code': '1f94b', 'aliases': ['keikogi', 'dogi', 'martial_arts']}), - ("black_cat", {'code': '1f408-200d-2b1b', 'aliases': ['black', 'unlucky']}), - ("black_circle", {'code': '26ab', 'aliases': []}), - ("black_flag", {'code': '1f3f4', 'aliases': []}), - ("black_heart", {'code': '1f5a4', 'aliases': []}), - ("black_large_square", {'code': '2b1b', 'aliases': []}), - ("black_medium_small_square", {'code': '25fe', 'aliases': []}), - ("black_medium_square", {'code': '25fc', 'aliases': []}), - ("black_nib", {'code': '2712', 'aliases': ['nib']}), - ("black_small_square", {'code': '25aa', 'aliases': []}), - ("blossom", {'code': '1f33c', 'aliases': []}), - ("blowfish", {'code': '1f421', 'aliases': []}), - ("blue_book", {'code': '1f4d8', 'aliases': []}), - ("blue_circle", {'code': '1f535', 'aliases': []}), - ("blue_heart", {'code': '1f499', 'aliases': []}), - ("blue_square", {'code': '1f7e6', 'aliases': []}), - ("blueberries", {'code': '1fad0', 'aliases': ['berry', 'bilberry', 'blueberry']}), - ("blush", {'code': '1f60a', 'aliases': []}), - ("boar", {'code': '1f417', 'aliases': []}), - ("boat", {'code': '26f5', 'aliases': ['sailboat']}), - ("bomb", {'code': '1f4a3', 'aliases': []}), - ("bone", {'code': '1f9b4', 'aliases': []}), - ("book", {'code': '1f4d6', 'aliases': ['open_book']}), - ("bookmark", {'code': '1f516', 'aliases': []}), - ("books", {'code': '1f4da', 'aliases': []}), - ("boom", {'code': '1f4a5', 'aliases': ['explosion', 'crash', 'collision']}), - ("boomerang", {'code': '1fa83', 'aliases': ['rebound', 'repercussion']}), - ("boot", {'code': '1f462', 'aliases': []}), - ("bouquet", {'code': '1f490', 'aliases': []}), - ("bow", {'code': '1f647', 'aliases': []}), - ("bow_and_arrow", {'code': '1f3f9', 'aliases': ['archery']}), - ("bowl_with_spoon", {'code': '1f963', 'aliases': ['cereal', 'congee']}), - ("boxing_glove", {'code': '1f94a', 'aliases': []}), - ("boy", {'code': '1f466', 'aliases': []}), - ("brain", {'code': '1f9e0', 'aliases': ['intelligent']}), - ("bread", {'code': '1f35e', 'aliases': []}), - ("breast_feeding", {'code': '1f931', 'aliases': ['breast']}), - ("brick", {'code': '1f9f1', 'aliases': ['bricks', 'clay', 'mortar', 'wall']}), - ("bride", {'code': '1f470', 'aliases': []}), - ("bridge", {'code': '1f309', 'aliases': []}), - ("briefcase", {'code': '1f4bc', 'aliases': []}), - ("briefs", {'code': '1fa72', 'aliases': ['one_piece', 'swimsuit']}), - ("brightness", {'code': '1f506', 'aliases': ['high_brightness']}), - ("broccoli", {'code': '1f966', 'aliases': ['wild_cabbage']}), - ("broken_heart", {'code': '1f494', 'aliases': ['heartache']}), - ("broom", {'code': '1f9f9', 'aliases': ['sweeping']}), - ("brown_circle", {'code': '1f7e4', 'aliases': []}), - ("brown_heart", {'code': '1f90e', 'aliases': []}), - ("brown_square", {'code': '1f7eb', 'aliases': []}), - ("bubble_tea", {'code': '1f9cb', 'aliases': []}), - ("bubbles", {'code': '1fae7', 'aliases': ['burp', 'underwater']}), - ("bucket", {'code': '1faa3', 'aliases': ['cask', 'pail', 'vat']}), - ("bug", {'code': '1f41b', 'aliases': ['caterpillar']}), - ("bullet_train", {'code': '1f685', 'aliases': []}), - ("bunny", {'code': '1f430', 'aliases': []}), - ("burrito", {'code': '1f32f', 'aliases': []}), - ("bus", {'code': '1f68c', 'aliases': ['school_bus']}), - ("bus_stop", {'code': '1f68f', 'aliases': []}), - ("butter", {'code': '1f9c8', 'aliases': ['dairy']}), - ("butterfly", {'code': '1f98b', 'aliases': []}), - ("cactus", {'code': '1f335', 'aliases': []}), - ("cake", {'code': '1f370', 'aliases': []}), - ("calendar", {'code': '1f4c5', 'aliases': []}), - ("calf", {'code': '1f42e', 'aliases': []}), - ("call_me", {'code': '1f919', 'aliases': []}), - ("calling", {'code': '1f4f2', 'aliases': []}), - ("camel", {'code': '1f42b', 'aliases': []}), - ("camera", {'code': '1f4f7', 'aliases': []}), - ("campsite", {'code': '1f3d5', 'aliases': []}), - ("cancer", {'code': '264b', 'aliases': []}), - ("candle", {'code': '1f56f', 'aliases': []}), - ("candy", {'code': '1f36c', 'aliases': []}), - ("canned_food", {'code': '1f96b', 'aliases': ['can']}), - ("canoe", {'code': '1f6f6', 'aliases': []}), - ("capital_abcd", {'code': '1f520', 'aliases': ['capital_letters']}), - ("capricorn", {'code': '2651', 'aliases': []}), - ("car", {'code': '1f697', 'aliases': []}), - ("carousel", {'code': '1f3a0', 'aliases': ['merry_go_round']}), - ("carp_streamer", {'code': '1f38f', 'aliases': ['flags']}), - ("carpenter_square", {'code': '1f4d0', 'aliases': ['triangular_ruler']}), - ("carpentry_saw", {'code': '1fa9a', 'aliases': ['carpenter', 'saw']}), - ("carrot", {'code': '1f955', 'aliases': []}), - ("cartwheel", {'code': '1f938', 'aliases': ['acrobatics', 'gymnastics', 'tumbling']}), - ("castle", {'code': '1f3f0', 'aliases': []}), - ("cat", {'code': '1f408', 'aliases': ['meow']}), - ("cd", {'code': '1f4bf', 'aliases': []}), - ("cell_reception", {'code': '1f4f6', 'aliases': ['signal_strength', 'signal_bars']}), - ("chains", {'code': '26d3', 'aliases': []}), - ("chair", {'code': '1fa91', 'aliases': ['sit']}), - ("champagne", {'code': '1f37e', 'aliases': []}), - ("chart", {'code': '1f4c8', 'aliases': ['upwards_trend', 'growing', 'increasing']}), - ("check", {'code': '2705', 'aliases': ['all_good', 'approved']}), - ("check_mark", {'code': '2714', 'aliases': []}), - ("checkbox", {'code': '2611', 'aliases': []}), - ("checkered_flag", {'code': '1f3c1', 'aliases': ['race', 'go', 'start']}), - ("cheese", {'code': '1f9c0', 'aliases': []}), - ("cherries", {'code': '1f352', 'aliases': []}), - ("cherry_blossom", {'code': '1f338', 'aliases': []}), - ("chess_pawn", {'code': '265f', 'aliases': ['chess', 'dupe', 'expendable']}), - ("chestnut", {'code': '1f330', 'aliases': []}), - ("chick", {'code': '1f424', 'aliases': ['baby_chick']}), - ("chicken", {'code': '1f414', 'aliases': ['cluck']}), - ("child", {'code': '1f9d2', 'aliases': ['young']}), - ("children_crossing", {'code': '1f6b8', 'aliases': ['school_crossing', 'drive_with_care']}), - ("chipmunk", {'code': '1f43f', 'aliases': []}), - ("chocolate", {'code': '1f36b', 'aliases': []}), - ("chopsticks", {'code': '1f962', 'aliases': ['hashi']}), - ("church", {'code': '26ea', 'aliases': []}), - ("cinema", {'code': '1f3a6', 'aliases': ['movie_theater']}), - ("circle", {'code': '2b55', 'aliases': []}), - ("circus", {'code': '1f3aa', 'aliases': []}), - ("city", {'code': '1f3d9', 'aliases': ['skyline']}), - ("city_sunrise", {'code': '1f307', 'aliases': []}), - ("cl", {'code': '1f191', 'aliases': []}), - ("clap", {'code': '1f44f', 'aliases': ['applause']}), - ("classical_building", {'code': '1f3db', 'aliases': []}), - ("clink", {'code': '1f942', 'aliases': ['toast']}), - ("clipboard", {'code': '1f4cb', 'aliases': []}), - ("clockwise", {'code': '1f503', 'aliases': []}), - ("closed_mailbox", {'code': '1f4ea', 'aliases': []}), - ("closed_umbrella", {'code': '1f302', 'aliases': []}), - ("clothing", {'code': '1f45a', 'aliases': []}), - ("cloud", {'code': '2601', 'aliases': ['overcast']}), - ("cloudy", {'code': '1f325', 'aliases': []}), - ("clown", {'code': '1f921', 'aliases': []}), - ("clubs", {'code': '2663', 'aliases': []}), - ("coat", {'code': '1f9e5', 'aliases': ['jacket']}), - ("cockroach", {'code': '1fab3', 'aliases': ['roach']}), - ("cocktail", {'code': '1f378', 'aliases': []}), - ("coconut", {'code': '1f965', 'aliases': ['piña_colada', 'pina_colada']}), - ("coffee", {'code': '2615', 'aliases': []}), - ("coffin", {'code': '26b0', 'aliases': ['burial', 'grave']}), - ("coin", {'code': '1fa99', 'aliases': ['metal']}), - ("cold_face", {'code': '1f976', 'aliases': ['blue_faced', 'freezing', 'frostbite', 'icicles']}), - ("cold_sweat", {'code': '1f630', 'aliases': []}), - ("comet", {'code': '2604', 'aliases': ['meteor']}), - ("compass", {'code': '1f9ed', 'aliases': ['navigation', 'orienteering']}), - ("compression", {'code': '1f5dc', 'aliases': ['vise']}), - ("computer", {'code': '1f4bb', 'aliases': ['laptop']}), - ("computer_mouse", {'code': '1f5b1', 'aliases': []}), - ("confetti", {'code': '1f38a', 'aliases': ['party_ball']}), - ("confounded", {'code': '1f616', 'aliases': ['agony']}), - ("construction", {'code': '1f3d7', 'aliases': []}), - ("construction_worker", {'code': '1f477', 'aliases': []}), - ("control_knobs", {'code': '1f39b', 'aliases': []}), - ("convenience_store", {'code': '1f3ea', 'aliases': []}), - ("cook", {'code': '1f9d1-200d-1f373', 'aliases': []}), - ("cookie", {'code': '1f36a', 'aliases': []}), - ("cooking", {'code': '1f373', 'aliases': []}), - ("cool", {'code': '1f192', 'aliases': []}), - ("copyright", {'code': '00a9', 'aliases': ['c']}), - ("coral", {'code': '1fab8', 'aliases': ['reef']}), - ("corn", {'code': '1f33d', 'aliases': ['maize']}), - ("counterclockwise", {'code': '1f504', 'aliases': ['return']}), - ("couple_with_heart", {'code': '1f491', 'aliases': []}), - ("couple_with_heart_man_man", {'code': '1f468-200d-2764-200d-1f468', 'aliases': []}), - ("couple_with_heart_woman_man", {'code': '1f469-200d-2764-200d-1f468', 'aliases': []}), - ("couple_with_heart_woman_woman", {'code': '1f469-200d-2764-200d-1f469', 'aliases': []}), - ("cow", {'code': '1f404', 'aliases': []}), - ("cowboy", {'code': '1f920', 'aliases': []}), - ("crab", {'code': '1f980', 'aliases': []}), - ("crayon", {'code': '1f58d', 'aliases': []}), - ("credit_card", {'code': '1f4b3', 'aliases': ['debit_card']}), - ("cricket", {'code': '1f997', 'aliases': ['grasshopper']}), - ("cricket_game", {'code': '1f3cf', 'aliases': []}), - ("crocodile", {'code': '1f40a', 'aliases': []}), - ("croissant", {'code': '1f950', 'aliases': []}), - ("cross", {'code': '271d', 'aliases': ['christianity']}), - ("cross_mark", {'code': '274c', 'aliases': ['incorrect', 'wrong']}), - ("crossed_flags", {'code': '1f38c', 'aliases': ['solidarity']}), - ("crown", {'code': '1f451', 'aliases': ['queen', 'king']}), - ("crutch", {'code': '1fa7c', 'aliases': ['cane', 'disability', 'mobility_aid']}), - ("cry", {'code': '1f622', 'aliases': []}), - ("crying_cat", {'code': '1f63f', 'aliases': []}), - ("crystal_ball", {'code': '1f52e', 'aliases': ['oracle', 'future', 'fortune_telling']}), - ("cucumber", {'code': '1f952', 'aliases': []}), - ("cup_with_straw", {'code': '1f964', 'aliases': ['soda']}), - ("cupcake", {'code': '1f9c1', 'aliases': []}), - ("cupid", {'code': '1f498', 'aliases': ['smitten', 'heart_arrow']}), - ("curling_stone", {'code': '1f94c', 'aliases': []}), - ("curry", {'code': '1f35b', 'aliases': []}), - ("custard", {'code': '1f36e', 'aliases': ['flan']}), - ("customs", {'code': '1f6c3', 'aliases': []}), - ("cut_of_meat", {'code': '1f969', 'aliases': ['lambchop', 'porkchop', 'steak']}), - ("cute", {'code': '1f4a0', 'aliases': ['kawaii', 'diamond_with_a_dot']}), - ("cyclist", {'code': '1f6b4', 'aliases': []}), - ("cyclone", {'code': '1f300', 'aliases': ['hurricane', 'typhoon']}), - ("dagger", {'code': '1f5e1', 'aliases': ['rated_for_violence']}), - ("dancer", {'code': '1f483', 'aliases': []}), - ("dancers", {'code': '1f46f', 'aliases': []}), - ("dancing", {'code': '1f57a', 'aliases': ['disco']}), - ("dango", {'code': '1f361', 'aliases': []}), - ("dark_sunglasses", {'code': '1f576', 'aliases': []}), - ("dash", {'code': '1f4a8', 'aliases': []}), - ("date", {'code': '1f4c6', 'aliases': []}), - ("deaf_man", {'code': '1f9cf-200d-2642', 'aliases': []}), - ("deaf_person", {'code': '1f9cf', 'aliases': ['hear']}), - ("deaf_woman", {'code': '1f9cf-200d-2640', 'aliases': []}), - ("decorative_notebook", {'code': '1f4d4', 'aliases': []}), - ("deer", {'code': '1f98c', 'aliases': []}), - ("department_store", {'code': '1f3ec', 'aliases': []}), - ("derelict_house", {'code': '1f3da', 'aliases': ['condemned']}), - ("desert", {'code': '1f3dc', 'aliases': []}), - ("desktop_computer", {'code': '1f5a5', 'aliases': []}), - ("detective", {'code': '1f575', 'aliases': ['spy', 'sleuth', 'agent', 'sneaky']}), - ("devil", {'code': '1f47f', 'aliases': ['imp', 'angry_devil']}), - ("diamonds", {'code': '2666', 'aliases': []}), - ("dice", {'code': '1f3b2', 'aliases': ['die']}), - ("direct_hit", {'code': '1f3af', 'aliases': ['darts', 'bulls_eye']}), - ("disappointed", {'code': '1f61e', 'aliases': []}), - ("disguised_face", {'code': '1f978', 'aliases': ['disguise', 'incognito']}), - ("diving_mask", {'code': '1f93f', 'aliases': ['scuba', 'snorkeling']}), - ("division", {'code': '2797', 'aliases': ['divide']}), - ("diya_lamp", {'code': '1fa94', 'aliases': ['diya', 'lamp', 'oil']}), - ("dizzy", {'code': '1f635', 'aliases': []}), - ("dna", {'code': '1f9ec', 'aliases': ['evolution', 'gene', 'genetics', 'life']}), - ("do_not_litter", {'code': '1f6af', 'aliases': []}), - ("document", {'code': '1f4c4', 'aliases': ['paper', 'file', 'page']}), - ("dodo", {'code': '1f9a4', 'aliases': ['mauritius']}), - ("dog", {'code': '1f415', 'aliases': ['woof']}), - ("dollar_bills", {'code': '1f4b5', 'aliases': []}), - ("dollars", {'code': '1f4b2', 'aliases': []}), - ("dolls", {'code': '1f38e', 'aliases': []}), - ("dolphin", {'code': '1f42c', 'aliases': ['flipper']}), - ("doner_kebab", {'code': '1f959', 'aliases': ['shawarma', 'souvlaki', 'stuffed_flatbread']}), - ("donut", {'code': '1f369', 'aliases': ['doughnut']}), - ("door", {'code': '1f6aa', 'aliases': []}), - ("dormouse", {'code': '1f42d', 'aliases': []}), - ("dotted_line_face", {'code': '1fae5', 'aliases': ['depressed', 'hide', 'introvert', 'invisible']}), - ("dotted_six_pointed_star", {'code': '1f52f', 'aliases': ['fortune']}), - ("double_down", {'code': '23ec', 'aliases': ['fast_down']}), - ("double_loop", {'code': '27bf', 'aliases': ['voicemail']}), - ("double_up", {'code': '23eb', 'aliases': ['fast_up']}), - ("dove", {'code': '1f54a', 'aliases': ['dove_of_peace']}), - ("down", {'code': '2b07', 'aliases': ['south']}), - ("downvote", {'code': '1f53d', 'aliases': ['down_button', 'decrease']}), - ("downwards_trend", {'code': '1f4c9', 'aliases': ['shrinking', 'decreasing']}), - ("dragon", {'code': '1f409', 'aliases': []}), - ("dragon_face", {'code': '1f432', 'aliases': []}), - ("dress", {'code': '1f457', 'aliases': []}), - ("drooling", {'code': '1f924', 'aliases': []}), - ("drop", {'code': '1f4a7', 'aliases': ['water_drop']}), - ("drop_of_blood", {'code': '1fa78', 'aliases': ['bleed', 'blood_donation', 'injury', 'menstruation']}), - ("drum", {'code': '1f941', 'aliases': []}), - ("drumstick", {'code': '1f357', 'aliases': ['poultry']}), - ("duck", {'code': '1f986', 'aliases': []}), - ("duel", {'code': '2694', 'aliases': ['swords']}), - ("dumpling", {'code': '1f95f', 'aliases': ['empanada', 'gyōza', 'jiaozi', 'pierogi', 'potsticker', 'gyoza']}), - ("dvd", {'code': '1f4c0', 'aliases': []}), - ("e-mail", {'code': '1f4e7', 'aliases': []}), - ("eagle", {'code': '1f985', 'aliases': []}), - ("ear", {'code': '1f442', 'aliases': []}), - ("ear_with_hearing_aid", {'code': '1f9bb', 'aliases': ['hard_of_hearing']}), - ("earth_africa", {'code': '1f30d', 'aliases': []}), - ("earth_americas", {'code': '1f30e', 'aliases': []}), - ("earth_asia", {'code': '1f30f', 'aliases': []}), - ("egg", {'code': '1f95a', 'aliases': []}), - ("eggplant", {'code': '1f346', 'aliases': []}), - ("eight", {'code': '0038-20e3', 'aliases': []}), - ("eight_pointed_star", {'code': '2734', 'aliases': []}), - ("eight_spoked_asterisk", {'code': '2733', 'aliases': []}), - ("eject_button", {'code': '23cf', 'aliases': ['eject']}), - ("electric_plug", {'code': '1f50c', 'aliases': []}), - ("elephant", {'code': '1f418', 'aliases': []}), - ("elevator", {'code': '1f6d7', 'aliases': ['hoist']}), - ("elf", {'code': '1f9dd', 'aliases': []}), - ("email", {'code': '2709', 'aliases': ['envelope', 'mail']}), - ("empty_nest", {'code': '1fab9', 'aliases': []}), - ("end", {'code': '1f51a', 'aliases': []}), - ("euro_banknotes", {'code': '1f4b6', 'aliases': []}), - ("evergreen_tree", {'code': '1f332', 'aliases': []}), - ("exchange", {'code': '1f4b1', 'aliases': []}), - ("exclamation", {'code': '2757', 'aliases': []}), - ("exhausted", {'code': '1f625', 'aliases': ['disappointed_relieved', 'stressed']}), - ("exploding_head", {'code': '1f92f', 'aliases': ['mind_blown', 'shocked']}), - ("expressionless", {'code': '1f611', 'aliases': []}), - ("eye", {'code': '1f441', 'aliases': []}), - ("eye_in_speech_bubble", {'code': '1f441-fe0f-200d-1f5e8-fe0f', 'aliases': ['speech', 'witness']}), - ("eyes", {'code': '1f440', 'aliases': ['looking']}), - ("face_exhaling", {'code': '1f62e-200d-1f4a8', 'aliases': ['exhale', 'gasp', 'groan', 'relief', 'whisper', 'whistle']}), - ("face_holding_back_tears", {'code': '1f979', 'aliases': ['resist']}), - ("face_in_clouds", {'code': '1f636-200d-1f32b', 'aliases': ['absentminded', 'face_in_the_fog', 'head_in_clouds']}), - ("face_palm", {'code': '1f926', 'aliases': []}), - ("face_vomiting", {'code': '1f92e', 'aliases': ['puke', 'vomit']}), - ("face_with_diagonal_mouth", {'code': '1fae4', 'aliases': ['meh', 'skeptical', 'unsure']}), - ("face_with_hand_over_mouth", {'code': '1f92d', 'aliases': ['whoops']}), - ("face_with_monocle", {'code': '1f9d0', 'aliases': ['monocle', 'stuffy']}), - ("face_with_open_eyes_and_hand_over_mouth", {'code': '1fae2', 'aliases': ['amazement', 'awe', 'embarrass']}), - ("face_with_peeking_eye", {'code': '1fae3', 'aliases': ['captivated', 'peep', 'stare']}), - ("face_with_raised_eyebrow", {'code': '1f928', 'aliases': ['distrust', 'skeptic']}), - ("face_with_spiral_eyes", {'code': '1f635-200d-1f4ab', 'aliases': ['hypnotized', 'trouble', 'whoa']}), - ("face_with_symbols_on_mouth", {'code': '1f92c', 'aliases': ['swearing']}), - ("factory", {'code': '1f3ed', 'aliases': []}), - ("factory_worker", {'code': '1f9d1-200d-1f3ed', 'aliases': []}), - ("fairy", {'code': '1f9da', 'aliases': []}), - ("falafel", {'code': '1f9c6', 'aliases': ['chickpea', 'meatball']}), - ("fallen_leaf", {'code': '1f342', 'aliases': []}), - ("family", {'code': '1f46a', 'aliases': []}), - ("family_man_boy", {'code': '1f468-200d-1f466', 'aliases': []}), - ("family_man_boy_boy", {'code': '1f468-200d-1f466-200d-1f466', 'aliases': []}), - ("family_man_girl", {'code': '1f468-200d-1f467', 'aliases': []}), - ("family_man_girl_boy", {'code': '1f468-200d-1f467-200d-1f466', 'aliases': []}), - ("family_man_girl_girl", {'code': '1f468-200d-1f467-200d-1f467', 'aliases': []}), - ("family_man_man_boy", {'code': '1f468-200d-1f468-200d-1f466', 'aliases': []}), - ("family_man_man_boy_boy", {'code': '1f468-200d-1f468-200d-1f466-200d-1f466', 'aliases': []}), - ("family_man_man_girl", {'code': '1f468-200d-1f468-200d-1f467', 'aliases': []}), - ("family_man_man_girl_boy", {'code': '1f468-200d-1f468-200d-1f467-200d-1f466', 'aliases': []}), - ("family_man_man_girl_girl", {'code': '1f468-200d-1f468-200d-1f467-200d-1f467', 'aliases': []}), - ("family_man_woman_boy", {'code': '1f468-200d-1f469-200d-1f466', 'aliases': []}), - ("family_man_woman_boy_boy", {'code': '1f468-200d-1f469-200d-1f466-200d-1f466', 'aliases': []}), - ("family_man_woman_girl", {'code': '1f468-200d-1f469-200d-1f467', 'aliases': []}), - ("family_man_woman_girl_boy", {'code': '1f468-200d-1f469-200d-1f467-200d-1f466', 'aliases': []}), - ("family_man_woman_girl_girl", {'code': '1f468-200d-1f469-200d-1f467-200d-1f467', 'aliases': []}), - ("family_woman_boy", {'code': '1f469-200d-1f466', 'aliases': []}), - ("family_woman_boy_boy", {'code': '1f469-200d-1f466-200d-1f466', 'aliases': []}), - ("family_woman_girl", {'code': '1f469-200d-1f467', 'aliases': []}), - ("family_woman_girl_boy", {'code': '1f469-200d-1f467-200d-1f466', 'aliases': []}), - ("family_woman_girl_girl", {'code': '1f469-200d-1f467-200d-1f467', 'aliases': []}), - ("family_woman_woman_boy", {'code': '1f469-200d-1f469-200d-1f466', 'aliases': []}), - ("family_woman_woman_boy_boy", {'code': '1f469-200d-1f469-200d-1f466-200d-1f466', 'aliases': []}), - ("family_woman_woman_girl", {'code': '1f469-200d-1f469-200d-1f467', 'aliases': []}), - ("family_woman_woman_girl_boy", {'code': '1f469-200d-1f469-200d-1f467-200d-1f466', 'aliases': []}), - ("family_woman_woman_girl_girl", {'code': '1f469-200d-1f469-200d-1f467-200d-1f467', 'aliases': []}), - ("farmer", {'code': '1f9d1-200d-1f33e', 'aliases': []}), - ("fast_forward", {'code': '23e9', 'aliases': []}), - ("fax", {'code': '1f4e0', 'aliases': []}), - ("fear", {'code': '1f628', 'aliases': ['scared', 'shock']}), - ("feather", {'code': '1fab6', 'aliases': ['flight', 'light', 'plumage']}), - ("female_sign", {'code': '2640', 'aliases': []}), - ("fencing", {'code': '1f93a', 'aliases': []}), - ("ferris_wheel", {'code': '1f3a1', 'aliases': []}), - ("ferry", {'code': '26f4', 'aliases': []}), - ("field_hockey", {'code': '1f3d1', 'aliases': []}), - ("file_cabinet", {'code': '1f5c4', 'aliases': []}), - ("film", {'code': '1f39e', 'aliases': []}), - ("fingers_crossed", {'code': '1f91e', 'aliases': []}), - ("fire", {'code': '1f525', 'aliases': ['lit', 'hot', 'flame']}), - ("fire_extinguisher", {'code': '1f9ef', 'aliases': ['extinguish', 'quench']}), - ("fire_truck", {'code': '1f692', 'aliases': ['fire_engine']}), - ("firecracker", {'code': '1f9e8', 'aliases': ['dynamite', 'explosive']}), - ("firefighter", {'code': '1f9d1-200d-1f692', 'aliases': []}), - ("fireworks", {'code': '1f386', 'aliases': []}), - ("first_place", {'code': '1f947', 'aliases': ['gold', 'number_one']}), - ("first_quarter_moon", {'code': '1f313', 'aliases': []}), - ("fish", {'code': '1f41f', 'aliases': []}), - ("fishing", {'code': '1f3a3', 'aliases': []}), - ("fist", {'code': '270a', 'aliases': ['power']}), - ("fist_bump", {'code': '1f44a', 'aliases': ['punch']}), - ("five", {'code': '0035-20e3', 'aliases': []}), - ("fixing", {'code': '1f527', 'aliases': ['wrench']}), - ("flag_afghanistan", {'code': '1f1e6-1f1eb', 'aliases': []}), - ("flag_albania", {'code': '1f1e6-1f1f1', 'aliases': []}), - ("flag_algeria", {'code': '1f1e9-1f1ff', 'aliases': []}), - ("flag_american_samoa", {'code': '1f1e6-1f1f8', 'aliases': []}), - ("flag_andorra", {'code': '1f1e6-1f1e9', 'aliases': []}), - ("flag_angola", {'code': '1f1e6-1f1f4', 'aliases': []}), - ("flag_anguilla", {'code': '1f1e6-1f1ee', 'aliases': []}), - ("flag_antarctica", {'code': '1f1e6-1f1f6', 'aliases': []}), - ("flag_antigua_and_barbuda", {'code': '1f1e6-1f1ec', 'aliases': []}), - ("flag_argentina", {'code': '1f1e6-1f1f7', 'aliases': []}), - ("flag_armenia", {'code': '1f1e6-1f1f2', 'aliases': []}), - ("flag_aruba", {'code': '1f1e6-1f1fc', 'aliases': []}), - ("flag_ascension_island", {'code': '1f1e6-1f1e8', 'aliases': []}), - ("flag_australia", {'code': '1f1e6-1f1fa', 'aliases': []}), - ("flag_austria", {'code': '1f1e6-1f1f9', 'aliases': []}), - ("flag_azerbaijan", {'code': '1f1e6-1f1ff', 'aliases': []}), - ("flag_bahamas", {'code': '1f1e7-1f1f8', 'aliases': []}), - ("flag_bahrain", {'code': '1f1e7-1f1ed', 'aliases': []}), - ("flag_bangladesh", {'code': '1f1e7-1f1e9', 'aliases': []}), - ("flag_barbados", {'code': '1f1e7-1f1e7', 'aliases': []}), - ("flag_belarus", {'code': '1f1e7-1f1fe', 'aliases': []}), - ("flag_belgium", {'code': '1f1e7-1f1ea', 'aliases': []}), - ("flag_belize", {'code': '1f1e7-1f1ff', 'aliases': []}), - ("flag_benin", {'code': '1f1e7-1f1ef', 'aliases': []}), - ("flag_bermuda", {'code': '1f1e7-1f1f2', 'aliases': []}), - ("flag_bhutan", {'code': '1f1e7-1f1f9', 'aliases': []}), - ("flag_bolivia", {'code': '1f1e7-1f1f4', 'aliases': []}), - ("flag_bosnia_and_herzegovina", {'code': '1f1e7-1f1e6', 'aliases': []}), - ("flag_botswana", {'code': '1f1e7-1f1fc', 'aliases': []}), - ("flag_bouvet_island", {'code': '1f1e7-1f1fb', 'aliases': []}), - ("flag_brazil", {'code': '1f1e7-1f1f7', 'aliases': []}), - ("flag_british_indian_ocean_territory", {'code': '1f1ee-1f1f4', 'aliases': []}), - ("flag_british_virgin_islands", {'code': '1f1fb-1f1ec', 'aliases': []}), - ("flag_brunei", {'code': '1f1e7-1f1f3', 'aliases': []}), - ("flag_bulgaria", {'code': '1f1e7-1f1ec', 'aliases': []}), - ("flag_burkina_faso", {'code': '1f1e7-1f1eb', 'aliases': []}), - ("flag_burundi", {'code': '1f1e7-1f1ee', 'aliases': []}), - ("flag_cambodia", {'code': '1f1f0-1f1ed', 'aliases': []}), - ("flag_cameroon", {'code': '1f1e8-1f1f2', 'aliases': []}), - ("flag_canada", {'code': '1f1e8-1f1e6', 'aliases': []}), - ("flag_canary_islands", {'code': '1f1ee-1f1e8', 'aliases': []}), - ("flag_cape_verde", {'code': '1f1e8-1f1fb', 'aliases': []}), - ("flag_caribbean_netherlands", {'code': '1f1e7-1f1f6', 'aliases': []}), - ("flag_cayman_islands", {'code': '1f1f0-1f1fe', 'aliases': []}), - ("flag_central_african_republic", {'code': '1f1e8-1f1eb', 'aliases': []}), - ("flag_ceuta_and_melilla", {'code': '1f1ea-1f1e6', 'aliases': []}), - ("flag_chad", {'code': '1f1f9-1f1e9', 'aliases': []}), - ("flag_chile", {'code': '1f1e8-1f1f1', 'aliases': []}), - ("flag_china", {'code': '1f1e8-1f1f3', 'aliases': []}), - ("flag_christmas_island", {'code': '1f1e8-1f1fd', 'aliases': []}), - ("flag_clipperton_island", {'code': '1f1e8-1f1f5', 'aliases': []}), - ("flag_cocos_(keeling)_islands", {'code': '1f1e8-1f1e8', 'aliases': []}), - ("flag_colombia", {'code': '1f1e8-1f1f4', 'aliases': []}), - ("flag_comoros", {'code': '1f1f0-1f1f2', 'aliases': []}), - ("flag_congo_brazzaville", {'code': '1f1e8-1f1ec', 'aliases': []}), - ("flag_congo_kinshasa", {'code': '1f1e8-1f1e9', 'aliases': []}), - ("flag_cook_islands", {'code': '1f1e8-1f1f0', 'aliases': []}), - ("flag_costa_rica", {'code': '1f1e8-1f1f7', 'aliases': []}), - ("flag_croatia", {'code': '1f1ed-1f1f7', 'aliases': []}), - ("flag_cuba", {'code': '1f1e8-1f1fa', 'aliases': []}), - ("flag_curaçao", {'code': '1f1e8-1f1fc', 'aliases': ['flag_curacao']}), - ("flag_cyprus", {'code': '1f1e8-1f1fe', 'aliases': []}), - ("flag_czechia", {'code': '1f1e8-1f1ff', 'aliases': []}), - ("flag_côte_d'ivoire", {'code': '1f1e8-1f1ee', 'aliases': ["flag_cote_d'ivoire"]}), - ("flag_denmark", {'code': '1f1e9-1f1f0', 'aliases': []}), - ("flag_diego_garcia", {'code': '1f1e9-1f1ec', 'aliases': []}), - ("flag_djibouti", {'code': '1f1e9-1f1ef', 'aliases': []}), - ("flag_dominica", {'code': '1f1e9-1f1f2', 'aliases': []}), - ("flag_dominican_republic", {'code': '1f1e9-1f1f4', 'aliases': []}), - ("flag_ecuador", {'code': '1f1ea-1f1e8', 'aliases': []}), - ("flag_egypt", {'code': '1f1ea-1f1ec', 'aliases': []}), - ("flag_el_salvador", {'code': '1f1f8-1f1fb', 'aliases': []}), - ("flag_england", {'code': '1f3f4-e0067-e0062-e0065-e006e-e0067-e007f', 'aliases': []}), - ("flag_equatorial_guinea", {'code': '1f1ec-1f1f6', 'aliases': []}), - ("flag_eritrea", {'code': '1f1ea-1f1f7', 'aliases': []}), - ("flag_estonia", {'code': '1f1ea-1f1ea', 'aliases': []}), - ("flag_eswatini", {'code': '1f1f8-1f1ff', 'aliases': []}), - ("flag_ethiopia", {'code': '1f1ea-1f1f9', 'aliases': []}), - ("flag_european_union", {'code': '1f1ea-1f1fa', 'aliases': []}), - ("flag_falkland_islands", {'code': '1f1eb-1f1f0', 'aliases': []}), - ("flag_faroe_islands", {'code': '1f1eb-1f1f4', 'aliases': []}), - ("flag_fiji", {'code': '1f1eb-1f1ef', 'aliases': []}), - ("flag_finland", {'code': '1f1eb-1f1ee', 'aliases': []}), - ("flag_france", {'code': '1f1eb-1f1f7', 'aliases': []}), - ("flag_french_guiana", {'code': '1f1ec-1f1eb', 'aliases': []}), - ("flag_french_polynesia", {'code': '1f1f5-1f1eb', 'aliases': []}), - ("flag_french_southern_territories", {'code': '1f1f9-1f1eb', 'aliases': []}), - ("flag_gabon", {'code': '1f1ec-1f1e6', 'aliases': []}), - ("flag_gambia", {'code': '1f1ec-1f1f2', 'aliases': []}), - ("flag_georgia", {'code': '1f1ec-1f1ea', 'aliases': []}), - ("flag_germany", {'code': '1f1e9-1f1ea', 'aliases': []}), - ("flag_ghana", {'code': '1f1ec-1f1ed', 'aliases': []}), - ("flag_gibraltar", {'code': '1f1ec-1f1ee', 'aliases': []}), - ("flag_greece", {'code': '1f1ec-1f1f7', 'aliases': []}), - ("flag_greenland", {'code': '1f1ec-1f1f1', 'aliases': []}), - ("flag_grenada", {'code': '1f1ec-1f1e9', 'aliases': []}), - ("flag_guadeloupe", {'code': '1f1ec-1f1f5', 'aliases': []}), - ("flag_guam", {'code': '1f1ec-1f1fa', 'aliases': []}), - ("flag_guatemala", {'code': '1f1ec-1f1f9', 'aliases': []}), - ("flag_guernsey", {'code': '1f1ec-1f1ec', 'aliases': []}), - ("flag_guinea", {'code': '1f1ec-1f1f3', 'aliases': []}), - ("flag_guinea_bissau", {'code': '1f1ec-1f1fc', 'aliases': []}), - ("flag_guyana", {'code': '1f1ec-1f1fe', 'aliases': []}), - ("flag_haiti", {'code': '1f1ed-1f1f9', 'aliases': []}), - ("flag_heard_and_mcdonald_islands", {'code': '1f1ed-1f1f2', 'aliases': []}), - ("flag_honduras", {'code': '1f1ed-1f1f3', 'aliases': []}), - ("flag_hong_kong_sar_china", {'code': '1f1ed-1f1f0', 'aliases': []}), - ("flag_hungary", {'code': '1f1ed-1f1fa', 'aliases': []}), - ("flag_iceland", {'code': '1f1ee-1f1f8', 'aliases': []}), - ("flag_india", {'code': '1f1ee-1f1f3', 'aliases': []}), - ("flag_indonesia", {'code': '1f1ee-1f1e9', 'aliases': []}), - ("flag_iran", {'code': '1f1ee-1f1f7', 'aliases': []}), - ("flag_iraq", {'code': '1f1ee-1f1f6', 'aliases': []}), - ("flag_ireland", {'code': '1f1ee-1f1ea', 'aliases': []}), - ("flag_isle_of_man", {'code': '1f1ee-1f1f2', 'aliases': []}), - ("flag_israel", {'code': '1f1ee-1f1f1', 'aliases': []}), - ("flag_italy", {'code': '1f1ee-1f1f9', 'aliases': []}), - ("flag_jamaica", {'code': '1f1ef-1f1f2', 'aliases': []}), - ("flag_japan", {'code': '1f1ef-1f1f5', 'aliases': []}), - ("flag_jersey", {'code': '1f1ef-1f1ea', 'aliases': []}), - ("flag_jordan", {'code': '1f1ef-1f1f4', 'aliases': []}), - ("flag_kazakhstan", {'code': '1f1f0-1f1ff', 'aliases': []}), - ("flag_kenya", {'code': '1f1f0-1f1ea', 'aliases': []}), - ("flag_kiribati", {'code': '1f1f0-1f1ee', 'aliases': []}), - ("flag_kosovo", {'code': '1f1fd-1f1f0', 'aliases': []}), - ("flag_kuwait", {'code': '1f1f0-1f1fc', 'aliases': []}), - ("flag_kyrgyzstan", {'code': '1f1f0-1f1ec', 'aliases': []}), - ("flag_laos", {'code': '1f1f1-1f1e6', 'aliases': []}), - ("flag_latvia", {'code': '1f1f1-1f1fb', 'aliases': []}), - ("flag_lebanon", {'code': '1f1f1-1f1e7', 'aliases': []}), - ("flag_lesotho", {'code': '1f1f1-1f1f8', 'aliases': []}), - ("flag_liberia", {'code': '1f1f1-1f1f7', 'aliases': []}), - ("flag_libya", {'code': '1f1f1-1f1fe', 'aliases': []}), - ("flag_liechtenstein", {'code': '1f1f1-1f1ee', 'aliases': []}), - ("flag_lithuania", {'code': '1f1f1-1f1f9', 'aliases': []}), - ("flag_luxembourg", {'code': '1f1f1-1f1fa', 'aliases': []}), - ("flag_macao_sar_china", {'code': '1f1f2-1f1f4', 'aliases': []}), - ("flag_madagascar", {'code': '1f1f2-1f1ec', 'aliases': []}), - ("flag_malawi", {'code': '1f1f2-1f1fc', 'aliases': []}), - ("flag_malaysia", {'code': '1f1f2-1f1fe', 'aliases': []}), - ("flag_maldives", {'code': '1f1f2-1f1fb', 'aliases': []}), - ("flag_mali", {'code': '1f1f2-1f1f1', 'aliases': []}), - ("flag_malta", {'code': '1f1f2-1f1f9', 'aliases': []}), - ("flag_marshall_islands", {'code': '1f1f2-1f1ed', 'aliases': []}), - ("flag_martinique", {'code': '1f1f2-1f1f6', 'aliases': []}), - ("flag_mauritania", {'code': '1f1f2-1f1f7', 'aliases': []}), - ("flag_mauritius", {'code': '1f1f2-1f1fa', 'aliases': []}), - ("flag_mayotte", {'code': '1f1fe-1f1f9', 'aliases': []}), - ("flag_mexico", {'code': '1f1f2-1f1fd', 'aliases': []}), - ("flag_micronesia", {'code': '1f1eb-1f1f2', 'aliases': []}), - ("flag_moldova", {'code': '1f1f2-1f1e9', 'aliases': []}), - ("flag_monaco", {'code': '1f1f2-1f1e8', 'aliases': []}), - ("flag_mongolia", {'code': '1f1f2-1f1f3', 'aliases': []}), - ("flag_montenegro", {'code': '1f1f2-1f1ea', 'aliases': []}), - ("flag_montserrat", {'code': '1f1f2-1f1f8', 'aliases': []}), - ("flag_morocco", {'code': '1f1f2-1f1e6', 'aliases': []}), - ("flag_mozambique", {'code': '1f1f2-1f1ff', 'aliases': []}), - ("flag_myanmar_(burma)", {'code': '1f1f2-1f1f2', 'aliases': []}), - ("flag_namibia", {'code': '1f1f3-1f1e6', 'aliases': []}), - ("flag_nauru", {'code': '1f1f3-1f1f7', 'aliases': []}), - ("flag_nepal", {'code': '1f1f3-1f1f5', 'aliases': []}), - ("flag_netherlands", {'code': '1f1f3-1f1f1', 'aliases': []}), - ("flag_new_caledonia", {'code': '1f1f3-1f1e8', 'aliases': []}), - ("flag_new_zealand", {'code': '1f1f3-1f1ff', 'aliases': []}), - ("flag_nicaragua", {'code': '1f1f3-1f1ee', 'aliases': []}), - ("flag_niger", {'code': '1f1f3-1f1ea', 'aliases': []}), - ("flag_nigeria", {'code': '1f1f3-1f1ec', 'aliases': []}), - ("flag_niue", {'code': '1f1f3-1f1fa', 'aliases': []}), - ("flag_norfolk_island", {'code': '1f1f3-1f1eb', 'aliases': []}), - ("flag_north_korea", {'code': '1f1f0-1f1f5', 'aliases': []}), - ("flag_north_macedonia", {'code': '1f1f2-1f1f0', 'aliases': []}), - ("flag_northern_mariana_islands", {'code': '1f1f2-1f1f5', 'aliases': []}), - ("flag_norway", {'code': '1f1f3-1f1f4', 'aliases': []}), - ("flag_oman", {'code': '1f1f4-1f1f2', 'aliases': []}), - ("flag_pakistan", {'code': '1f1f5-1f1f0', 'aliases': []}), - ("flag_palau", {'code': '1f1f5-1f1fc', 'aliases': []}), - ("flag_palestinian_territories", {'code': '1f1f5-1f1f8', 'aliases': []}), - ("flag_panama", {'code': '1f1f5-1f1e6', 'aliases': []}), - ("flag_papua_new_guinea", {'code': '1f1f5-1f1ec', 'aliases': []}), - ("flag_paraguay", {'code': '1f1f5-1f1fe', 'aliases': []}), - ("flag_peru", {'code': '1f1f5-1f1ea', 'aliases': []}), - ("flag_philippines", {'code': '1f1f5-1f1ed', 'aliases': []}), - ("flag_pitcairn_islands", {'code': '1f1f5-1f1f3', 'aliases': []}), - ("flag_poland", {'code': '1f1f5-1f1f1', 'aliases': []}), - ("flag_portugal", {'code': '1f1f5-1f1f9', 'aliases': []}), - ("flag_puerto_rico", {'code': '1f1f5-1f1f7', 'aliases': []}), - ("flag_qatar", {'code': '1f1f6-1f1e6', 'aliases': []}), - ("flag_romania", {'code': '1f1f7-1f1f4', 'aliases': []}), - ("flag_russia", {'code': '1f1f7-1f1fa', 'aliases': []}), - ("flag_rwanda", {'code': '1f1f7-1f1fc', 'aliases': []}), - ("flag_réunion", {'code': '1f1f7-1f1ea', 'aliases': ['flag_reunion']}), - ("flag_samoa", {'code': '1f1fc-1f1f8', 'aliases': []}), - ("flag_san_marino", {'code': '1f1f8-1f1f2', 'aliases': []}), - ("flag_saudi_arabia", {'code': '1f1f8-1f1e6', 'aliases': []}), - ("flag_scotland", {'code': '1f3f4-e0067-e0062-e0073-e0063-e0074-e007f', 'aliases': []}), - ("flag_senegal", {'code': '1f1f8-1f1f3', 'aliases': []}), - ("flag_serbia", {'code': '1f1f7-1f1f8', 'aliases': []}), - ("flag_seychelles", {'code': '1f1f8-1f1e8', 'aliases': []}), - ("flag_sierra_leone", {'code': '1f1f8-1f1f1', 'aliases': []}), - ("flag_singapore", {'code': '1f1f8-1f1ec', 'aliases': []}), - ("flag_sint_maarten", {'code': '1f1f8-1f1fd', 'aliases': []}), - ("flag_slovakia", {'code': '1f1f8-1f1f0', 'aliases': []}), - ("flag_slovenia", {'code': '1f1f8-1f1ee', 'aliases': []}), - ("flag_solomon_islands", {'code': '1f1f8-1f1e7', 'aliases': []}), - ("flag_somalia", {'code': '1f1f8-1f1f4', 'aliases': []}), - ("flag_south_africa", {'code': '1f1ff-1f1e6', 'aliases': []}), - ("flag_south_georgia_and_south_sandwich_islands", {'code': '1f1ec-1f1f8', 'aliases': []}), - ("flag_south_korea", {'code': '1f1f0-1f1f7', 'aliases': []}), - ("flag_south_sudan", {'code': '1f1f8-1f1f8', 'aliases': []}), - ("flag_spain", {'code': '1f1ea-1f1f8', 'aliases': []}), - ("flag_sri_lanka", {'code': '1f1f1-1f1f0', 'aliases': []}), - ("flag_st_barthélemy", {'code': '1f1e7-1f1f1', 'aliases': ['flag_st_barthelemy']}), - ("flag_st_helena", {'code': '1f1f8-1f1ed', 'aliases': []}), - ("flag_st_kitts_and_nevis", {'code': '1f1f0-1f1f3', 'aliases': []}), - ("flag_st_lucia", {'code': '1f1f1-1f1e8', 'aliases': []}), - ("flag_st_martin", {'code': '1f1f2-1f1eb', 'aliases': []}), - ("flag_st_pierre_and_miquelon", {'code': '1f1f5-1f1f2', 'aliases': []}), - ("flag_st_vincent_and_grenadines", {'code': '1f1fb-1f1e8', 'aliases': []}), - ("flag_sudan", {'code': '1f1f8-1f1e9', 'aliases': []}), - ("flag_suriname", {'code': '1f1f8-1f1f7', 'aliases': []}), - ("flag_svalbard_and_jan_mayen", {'code': '1f1f8-1f1ef', 'aliases': []}), - ("flag_sweden", {'code': '1f1f8-1f1ea', 'aliases': []}), - ("flag_switzerland", {'code': '1f1e8-1f1ed', 'aliases': []}), - ("flag_syria", {'code': '1f1f8-1f1fe', 'aliases': []}), - ("flag_são_tomé_and_príncipe", {'code': '1f1f8-1f1f9', 'aliases': ['flag_sao_tome_and_principe']}), - ("flag_taiwan", {'code': '1f1f9-1f1fc', 'aliases': []}), - ("flag_tajikistan", {'code': '1f1f9-1f1ef', 'aliases': []}), - ("flag_tanzania", {'code': '1f1f9-1f1ff', 'aliases': []}), - ("flag_thailand", {'code': '1f1f9-1f1ed', 'aliases': []}), - ("flag_timor_leste", {'code': '1f1f9-1f1f1', 'aliases': []}), - ("flag_togo", {'code': '1f1f9-1f1ec', 'aliases': []}), - ("flag_tokelau", {'code': '1f1f9-1f1f0', 'aliases': []}), - ("flag_tonga", {'code': '1f1f9-1f1f4', 'aliases': []}), - ("flag_trinidad_and_tobago", {'code': '1f1f9-1f1f9', 'aliases': []}), - ("flag_tristan_da_cunha", {'code': '1f1f9-1f1e6', 'aliases': []}), - ("flag_tunisia", {'code': '1f1f9-1f1f3', 'aliases': []}), - ("flag_turkey", {'code': '1f1f9-1f1f7', 'aliases': []}), - ("flag_turkmenistan", {'code': '1f1f9-1f1f2', 'aliases': []}), - ("flag_turks_and_caicos_islands", {'code': '1f1f9-1f1e8', 'aliases': []}), - ("flag_tuvalu", {'code': '1f1f9-1f1fb', 'aliases': []}), - ("flag_uganda", {'code': '1f1fa-1f1ec', 'aliases': []}), - ("flag_ukraine", {'code': '1f1fa-1f1e6', 'aliases': []}), - ("flag_united_arab_emirates", {'code': '1f1e6-1f1ea', 'aliases': []}), - ("flag_united_kingdom", {'code': '1f1ec-1f1e7', 'aliases': []}), - ("flag_united_nations", {'code': '1f1fa-1f1f3', 'aliases': []}), - ("flag_united_states", {'code': '1f1fa-1f1f8', 'aliases': []}), - ("flag_uruguay", {'code': '1f1fa-1f1fe', 'aliases': []}), - ("flag_us_outlying_islands", {'code': '1f1fa-1f1f2', 'aliases': []}), - ("flag_us_virgin_islands", {'code': '1f1fb-1f1ee', 'aliases': []}), - ("flag_uzbekistan", {'code': '1f1fa-1f1ff', 'aliases': []}), - ("flag_vanuatu", {'code': '1f1fb-1f1fa', 'aliases': []}), - ("flag_vatican_city", {'code': '1f1fb-1f1e6', 'aliases': []}), - ("flag_venezuela", {'code': '1f1fb-1f1ea', 'aliases': []}), - ("flag_vietnam", {'code': '1f1fb-1f1f3', 'aliases': []}), - ("flag_wales", {'code': '1f3f4-e0067-e0062-e0077-e006c-e0073-e007f', 'aliases': []}), - ("flag_wallis_and_futuna", {'code': '1f1fc-1f1eb', 'aliases': []}), - ("flag_western_sahara", {'code': '1f1ea-1f1ed', 'aliases': []}), - ("flag_yemen", {'code': '1f1fe-1f1ea', 'aliases': []}), - ("flag_zambia", {'code': '1f1ff-1f1f2', 'aliases': []}), - ("flag_zimbabwe", {'code': '1f1ff-1f1fc', 'aliases': []}), - ("flag_åland_islands", {'code': '1f1e6-1f1fd', 'aliases': ['flag_aland_islands']}), - ("flamingo", {'code': '1f9a9', 'aliases': ['flamboyant']}), - ("flashlight", {'code': '1f526', 'aliases': []}), - ("flat_shoe", {'code': '1f97f', 'aliases': ['ballet_flat', 'slip_on', 'slipper']}), - ("flatbread", {'code': '1fad3', 'aliases': ['arepa', 'lavash', 'naan', 'pita']}), - ("fleur_de_lis", {'code': '269c', 'aliases': []}), - ("floppy_disk", {'code': '1f4be', 'aliases': []}), - ("flushed", {'code': '1f633', 'aliases': ['embarrassed', 'blushing']}), - ("fly", {'code': '1fab0', 'aliases': ['maggot', 'rotting']}), - ("flying_disc", {'code': '1f94f', 'aliases': ['ultimate']}), - ("flying_saucer", {'code': '1f6f8', 'aliases': []}), - ("fog", {'code': '1f32b', 'aliases': ['hazy']}), - ("foggy", {'code': '1f301', 'aliases': []}), - ("folder", {'code': '1f4c2', 'aliases': []}), - ("fondue", {'code': '1fad5', 'aliases': ['melted', 'swiss']}), - ("food", {'code': '1f372', 'aliases': ['soup', 'stew']}), - ("foot", {'code': '1f9b6', 'aliases': ['stomp']}), - ("football", {'code': '26bd', 'aliases': ['soccer']}), - ("footprints", {'code': '1f463', 'aliases': ['feet']}), - ("fork_and_knife", {'code': '1f374', 'aliases': ['eating_utensils']}), - ("fortune_cookie", {'code': '1f960', 'aliases': ['prophecy']}), - ("forward", {'code': '21aa', 'aliases': ['right_hook']}), - ("fountain", {'code': '26f2', 'aliases': []}), - ("fountain_pen", {'code': '1f58b', 'aliases': []}), - ("four", {'code': '0034-20e3', 'aliases': []}), - ("fox", {'code': '1f98a', 'aliases': []}), - ("free", {'code': '1f193', 'aliases': []}), - ("fries", {'code': '1f35f', 'aliases': []}), - ("frog", {'code': '1f438', 'aliases': []}), - ("frosty", {'code': '26c4', 'aliases': []}), - ("frown", {'code': '1f641', 'aliases': ['slight_frown']}), - ("frowning", {'code': '1f626', 'aliases': []}), - ("fuel_pump", {'code': '26fd', 'aliases': ['gas_pump', 'petrol_pump']}), - ("full_moon", {'code': '1f315', 'aliases': []}), - ("funeral_urn", {'code': '26b1', 'aliases': ['cremation']}), - ("garlic", {'code': '1f9c4', 'aliases': []}), - ("gear", {'code': '2699', 'aliases': ['settings', 'mechanical', 'engineer']}), - ("gem", {'code': '1f48e', 'aliases': ['crystal']}), - ("gemini", {'code': '264a', 'aliases': []}), - ("genie", {'code': '1f9de', 'aliases': []}), - ("ghost", {'code': '1f47b', 'aliases': ['boo', 'spooky', 'haunted']}), - ("gift", {'code': '1f381', 'aliases': ['present']}), - ("gift_heart", {'code': '1f49d', 'aliases': []}), - ("giraffe", {'code': '1f992', 'aliases': ['spots']}), - ("girl", {'code': '1f467', 'aliases': []}), - ("glasses", {'code': '1f453', 'aliases': ['spectacles']}), - ("gloves", {'code': '1f9e4', 'aliases': []}), - ("glowing_star", {'code': '1f31f', 'aliases': []}), - ("goat", {'code': '1f410', 'aliases': []}), - ("goblin", {'code': '1f47a', 'aliases': []}), - ("goggles", {'code': '1f97d', 'aliases': ['eye_protection', 'swimming', 'welding']}), - ("gold_record", {'code': '1f4bd', 'aliases': ['minidisc']}), - ("golf", {'code': '1f3cc', 'aliases': []}), - ("gondola", {'code': '1f6a0', 'aliases': ['mountain_cableway']}), - ("goodnight", {'code': '1f31b', 'aliases': []}), - ("gooooooooal", {'code': '1f945', 'aliases': ['goal']}), - ("gorilla", {'code': '1f98d', 'aliases': []}), - ("graduate", {'code': '1f393', 'aliases': ['mortar_board']}), - ("grapes", {'code': '1f347', 'aliases': []}), - ("green_apple", {'code': '1f34f', 'aliases': []}), - ("green_book", {'code': '1f4d7', 'aliases': []}), - ("green_circle", {'code': '1f7e2', 'aliases': ['green']}), - ("green_heart", {'code': '1f49a', 'aliases': ['envy']}), - ("green_large_square", {'code': '1f7e9', 'aliases': []}), - ("grey_exclamation", {'code': '2755', 'aliases': []}), - ("grey_question", {'code': '2754', 'aliases': []}), - ("grimacing", {'code': '1f62c', 'aliases': ['nervous', 'anxious']}), - ("grinning", {'code': '1f600', 'aliases': ['happy']}), - ("grinning_face_with_smiling_eyes", {'code': '1f601', 'aliases': []}), - ("gua_pi_mao", {'code': '1f472', 'aliases': []}), - ("guard", {'code': '1f482', 'aliases': []}), - ("guide_dog", {'code': '1f9ae', 'aliases': ['guide']}), - ("guitar", {'code': '1f3b8', 'aliases': []}), - ("gun", {'code': '1f52b', 'aliases': []}), - ("haircut", {'code': '1f487', 'aliases': []}), - ("hamburger", {'code': '1f354', 'aliases': []}), - ("hammer", {'code': '1f528', 'aliases': ['maintenance', 'handyman', 'handywoman']}), - ("hamsa", {'code': '1faac', 'aliases': ['amulet', 'fatima', 'mary', 'miriam', 'protection']}), - ("hamster", {'code': '1f439', 'aliases': []}), - ("hand", {'code': '270b', 'aliases': ['raised_hand']}), - ("hand_with_index_finger_and_thumb_crossed", {'code': '1faf0', 'aliases': ['expensive', 'snap']}), - ("handbag", {'code': '1f45c', 'aliases': []}), - ("handball", {'code': '1f93e', 'aliases': []}), - ("handshake", {'code': '1f91d', 'aliases': ['done_deal']}), - ("harvest", {'code': '1f33e', 'aliases': ['ear_of_rice']}), - ("hash", {'code': '0023-20e3', 'aliases': []}), - ("hat", {'code': '1f452', 'aliases': []}), - ("hatching", {'code': '1f423', 'aliases': ['hatching_chick']}), - ("heading_down", {'code': '2935', 'aliases': []}), - ("heading_up", {'code': '2934', 'aliases': []}), - ("headlines", {'code': '1f4f0', 'aliases': []}), - ("headphones", {'code': '1f3a7', 'aliases': []}), - ("headstone", {'code': '1faa6', 'aliases': ['cemetery', 'graveyard', 'tombstone']}), - ("health_worker", {'code': '1f9d1-200d-2695', 'aliases': []}), - ("hear_no_evil", {'code': '1f649', 'aliases': []}), - ("heart", {'code': '2764', 'aliases': ['love', 'love_you']}), - ("heart_box", {'code': '1f49f', 'aliases': []}), - ("heart_exclamation", {'code': '2763', 'aliases': []}), - ("heart_eyes", {'code': '1f60d', 'aliases': ['in_love']}), - ("heart_eyes_cat", {'code': '1f63b', 'aliases': []}), - ("heart_hands", {'code': '1faf6', 'aliases': []}), - ("heart_kiss", {'code': '1f618', 'aliases': ['blow_a_kiss']}), - ("heart_on_fire", {'code': '2764-200d-1f525', 'aliases': ['burn', 'lust', 'sacred_heart']}), - ("heart_pulse", {'code': '1f497', 'aliases': ['growing_heart']}), - ("heartbeat", {'code': '1f493', 'aliases': []}), - ("hearts", {'code': '2665', 'aliases': []}), - ("heavy_equals_sign", {'code': '1f7f0', 'aliases': ['equality', 'math']}), - ("hedgehog", {'code': '1f994', 'aliases': ['spiny']}), - ("helicopter", {'code': '1f681', 'aliases': []}), - ("helmet", {'code': '26d1', 'aliases': ['hard_hat', 'rescue_worker', 'safety_first', 'invincible']}), - ("herb", {'code': '1f33f', 'aliases': ['plant']}), - ("hibiscus", {'code': '1f33a', 'aliases': []}), - ("high_five", {'code': '1f590', 'aliases': ['palm']}), - ("high_heels", {'code': '1f460', 'aliases': []}), - ("high_speed_train", {'code': '1f684', 'aliases': []}), - ("high_voltage", {'code': '26a1', 'aliases': ['zap']}), - ("hiking_boot", {'code': '1f97e', 'aliases': ['backpacking', 'hiking']}), - ("hindu_temple", {'code': '1f6d5', 'aliases': ['hindu', 'temple']}), - ("hippopotamus", {'code': '1f99b', 'aliases': ['hippo']}), - ("hole", {'code': '1f573', 'aliases': []}), - ("hole_in_one", {'code': '26f3', 'aliases': []}), - ("holiday_tree", {'code': '1f384', 'aliases': []}), - ("honey", {'code': '1f36f', 'aliases': []}), - ("hook", {'code': '1fa9d', 'aliases': ['crook', 'curve', 'ensnare', 'selling_point']}), - ("horizontal_traffic_light", {'code': '1f6a5', 'aliases': []}), - ("horn", {'code': '1f4ef', 'aliases': []}), - ("horse", {'code': '1f40e', 'aliases': []}), - ("horse_racing", {'code': '1f3c7', 'aliases': ['horse_riding']}), - ("hospital", {'code': '1f3e5', 'aliases': []}), - ("hot_face", {'code': '1f975', 'aliases': ['feverish', 'heat_stroke', 'red_faced', 'sweating']}), - ("hot_pepper", {'code': '1f336', 'aliases': ['chili_pepper']}), - ("hot_springs", {'code': '2668', 'aliases': []}), - ("hotdog", {'code': '1f32d', 'aliases': []}), - ("hotel", {'code': '1f3e8', 'aliases': []}), - ("house", {'code': '1f3e0', 'aliases': []}), - ("houses", {'code': '1f3d8', 'aliases': []}), - ("hug", {'code': '1f917', 'aliases': ['arms_open']}), - ("humpback_whale", {'code': '1f40b', 'aliases': []}), - ("hungry", {'code': '1f37d', 'aliases': ['meal', 'table_setting', 'fork_and_knife_with_plate', 'lets_eat']}), - ("hurt", {'code': '1f915', 'aliases': ['head_bandage', 'injured']}), - ("hushed", {'code': '1f62f', 'aliases': []}), - ("hut", {'code': '1f6d6', 'aliases': ['roundhouse', 'yurt']}), - ("ice", {'code': '1f9ca', 'aliases': ['ice_cube', 'iceberg']}), - ("ice_cream", {'code': '1f368', 'aliases': ['gelato']}), - ("ice_hockey", {'code': '1f3d2', 'aliases': []}), - ("ice_skate", {'code': '26f8', 'aliases': []}), - ("id", {'code': '1f194', 'aliases': []}), - ("identification_card", {'code': '1faaa', 'aliases': ['credentials', 'license', 'security']}), - ("in_bed", {'code': '1f6cc', 'aliases': ['accommodations', 'guestrooms']}), - ("inbox", {'code': '1f4e5', 'aliases': []}), - ("inbox_zero", {'code': '1f4ed', 'aliases': ['empty_mailbox', 'no_mail']}), - ("index_pointing_at_the_viewer", {'code': '1faf5', 'aliases': ['point', 'you']}), - ("infinity", {'code': '267e', 'aliases': ['forever', 'unbounded', 'universal']}), - ("info", {'code': '2139', 'aliases': []}), - ("information_desk_person", {'code': '1f481', 'aliases': ['person_tipping_hand']}), - ("injection", {'code': '1f489', 'aliases': ['syringe']}), - ("innocent", {'code': '1f607', 'aliases': ['halo']}), - ("interrobang", {'code': '2049', 'aliases': []}), - ("island", {'code': '1f3dd', 'aliases': []}), - ("jack-o-lantern", {'code': '1f383', 'aliases': ['pumpkin']}), - ("japan", {'code': '1f5fe', 'aliases': []}), - ("japan_post", {'code': '1f3e3', 'aliases': []}), - ("japanese_acceptable_button", {'code': '1f251', 'aliases': ['accept']}), - ("japanese_application_button", {'code': '1f238', 'aliases': ['u7533']}), - ("japanese_bargain_button", {'code': '1f250', 'aliases': ['ideograph_advantage']}), - ("japanese_congratulations_button", {'code': '3297', 'aliases': ['congratulations']}), - ("japanese_discount_button", {'code': '1f239', 'aliases': ['u5272']}), - ("japanese_free_of_charge_button", {'code': '1f21a', 'aliases': ['u7121']}), - ("japanese_here_button", {'code': '1f201', 'aliases': ['here', 'ココ']}), - ("japanese_monthly_amount_button", {'code': '1f237', 'aliases': ['u6708']}), - ("japanese_no_vacancy_button", {'code': '1f235', 'aliases': ['u6e80']}), - ("japanese_not_free_of_charge_button", {'code': '1f236', 'aliases': ['u6709']}), - ("japanese_open_for_business_button", {'code': '1f23a', 'aliases': ['u55b6']}), - ("japanese_passing_grade_button", {'code': '1f234', 'aliases': ['u5408']}), - ("japanese_prohibited_button", {'code': '1f232', 'aliases': ['u7981']}), - ("japanese_reserved_button", {'code': '1f22f', 'aliases': ['reserved', '指']}), - ("japanese_secret_button", {'code': '3299', 'aliases': []}), - ("japanese_service_charge_button", {'code': '1f202', 'aliases': ['service_charge', 'サ']}), - ("japanese_vacancy_button", {'code': '1f233', 'aliases': ['vacancy', '空']}), - ("jar", {'code': '1fad9', 'aliases': ['container', 'sauce', 'store']}), - ("jeans", {'code': '1f456', 'aliases': ['denim']}), - ("joker", {'code': '1f0cf', 'aliases': []}), - ("joy", {'code': '1f602', 'aliases': ['tears', 'laughter_tears']}), - ("joy_cat", {'code': '1f639', 'aliases': []}), - ("joystick", {'code': '1f579', 'aliases': ['arcade']}), - ("judge", {'code': '1f9d1-200d-2696', 'aliases': []}), - ("juggling", {'code': '1f939', 'aliases': []}), - ("justice", {'code': '2696', 'aliases': ['scales', 'balance']}), - ("kaaba", {'code': '1f54b', 'aliases': []}), - ("kangaroo", {'code': '1f998', 'aliases': ['joey', 'jump', 'marsupial']}), - ("key", {'code': '1f511', 'aliases': []}), - ("keyboard", {'code': '2328', 'aliases': []}), - ("kick_scooter", {'code': '1f6f4', 'aliases': []}), - ("kimono", {'code': '1f458', 'aliases': []}), - ("kiss", {'code': '1f48f', 'aliases': []}), - ("kiss_man_man", {'code': '1f468-200d-2764-200d-1f48b-200d-1f468', 'aliases': []}), - ("kiss_smiling_eyes", {'code': '1f619', 'aliases': []}), - ("kiss_with_blush", {'code': '1f61a', 'aliases': []}), - ("kiss_woman_man", {'code': '1f469-200d-2764-200d-1f48b-200d-1f468', 'aliases': []}), - ("kiss_woman_woman", {'code': '1f469-200d-2764-200d-1f48b-200d-1f469', 'aliases': []}), - ("kissing_cat", {'code': '1f63d', 'aliases': []}), - ("kissing_face", {'code': '1f617', 'aliases': []}), - ("kite", {'code': '1fa81', 'aliases': ['soar']}), - ("kitten", {'code': '1f431', 'aliases': []}), - ("kiwi", {'code': '1f95d', 'aliases': []}), - ("knife", {'code': '1f52a', 'aliases': ['hocho', 'betrayed']}), - ("knot", {'code': '1faa2', 'aliases': ['rope', 'tangled', 'twine', 'twist']}), - ("koala", {'code': '1f428', 'aliases': []}), - ("lab_coat", {'code': '1f97c', 'aliases': []}), - ("label", {'code': '1f3f7', 'aliases': ['tag', 'price_tag']}), - ("lacrosse", {'code': '1f94d', 'aliases': []}), - ("ladder", {'code': '1fa9c', 'aliases': ['climb', 'rung', 'step']}), - ("lady_beetle", {'code': '1f41e', 'aliases': ['ladybird', 'ladybug']}), - ("landing", {'code': '1f6ec', 'aliases': ['arrival', 'airplane_arrival']}), - ("landline", {'code': '1f4de', 'aliases': ['home_phone']}), - ("lantern", {'code': '1f3ee', 'aliases': ['izakaya_lantern']}), - ("large_blue_diamond", {'code': '1f537', 'aliases': []}), - ("large_orange_diamond", {'code': '1f536', 'aliases': []}), - ("last_quarter_moon", {'code': '1f317', 'aliases': []}), - ("last_quarter_moon_face", {'code': '1f31c', 'aliases': []}), - ("laughing", {'code': '1f606', 'aliases': ['lol']}), - ("leafy_green", {'code': '1f96c', 'aliases': ['bok_choy', 'cabbage', 'kale', 'lettuce']}), - ("leaves", {'code': '1f343', 'aliases': ['wind', 'fall']}), - ("ledger", {'code': '1f4d2', 'aliases': ['spiral_notebook']}), - ("left", {'code': '2b05', 'aliases': ['west']}), - ("left_fist", {'code': '1f91b', 'aliases': []}), - ("left_right", {'code': '2194', 'aliases': ['swap']}), - ("leftwards_hand", {'code': '1faf2', 'aliases': ['leftward']}), - ("leg", {'code': '1f9b5', 'aliases': ['limb']}), - ("lemon", {'code': '1f34b', 'aliases': []}), - ("leo", {'code': '264c', 'aliases': []}), - ("leopard", {'code': '1f406', 'aliases': []}), - ("levitating", {'code': '1f574', 'aliases': ['hover']}), - ("libra", {'code': '264e', 'aliases': []}), - ("lift", {'code': '1f3cb', 'aliases': ['work_out', 'weight_lift', 'gym']}), - ("light_bulb", {'code': '1f4a1', 'aliases': ['bulb', 'idea']}), - ("light_rail", {'code': '1f688', 'aliases': []}), - ("lightning", {'code': '1f329', 'aliases': ['lightning_storm']}), - ("link", {'code': '1f517', 'aliases': []}), - ("lion", {'code': '1f981', 'aliases': []}), - ("lips", {'code': '1f444', 'aliases': ['mouth']}), - ("lipstick", {'code': '1f484', 'aliases': []}), - ("lipstick_kiss", {'code': '1f48b', 'aliases': []}), - ("living_room", {'code': '1f6cb', 'aliases': ['furniture', 'couch_and_lamp', 'lifestyles']}), - ("lizard", {'code': '1f98e', 'aliases': ['gecko']}), - ("llama", {'code': '1f999', 'aliases': ['alpaca', 'guanaco', 'vicuña', 'wool', 'vicuna']}), - ("lobster", {'code': '1f99e', 'aliases': ['bisque', 'claws', 'seafood']}), - ("locked", {'code': '1f512', 'aliases': []}), - ("locker", {'code': '1f6c5', 'aliases': ['locked_bag']}), - ("lollipop", {'code': '1f36d', 'aliases': []}), - ("long_drum", {'code': '1fa98', 'aliases': ['beat', 'conga', 'rhythm']}), - ("loop", {'code': '27b0', 'aliases': []}), - ("losing_money", {'code': '1f4b8', 'aliases': ['easy_come_easy_go', 'money_with_wings']}), - ("lotion_bottle", {'code': '1f9f4', 'aliases': ['lotion', 'moisturizer', 'shampoo', 'sunscreen']}), - ("lotus", {'code': '1fab7', 'aliases': ['purity']}), - ("louder", {'code': '1f50a', 'aliases': ['sound']}), - ("loudspeaker", {'code': '1f4e2', 'aliases': ['bullhorn']}), - ("love_hotel", {'code': '1f3e9', 'aliases': []}), - ("love_letter", {'code': '1f48c', 'aliases': []}), - ("love_you_gesture", {'code': '1f91f', 'aliases': ['ily']}), - ("low_battery", {'code': '1faab', 'aliases': ['electronic', 'low_energy']}), - ("low_brightness", {'code': '1f505', 'aliases': ['dim']}), - ("lower_left", {'code': '2199', 'aliases': ['south_west']}), - ("lower_right", {'code': '2198', 'aliases': ['south_east']}), - ("lucky", {'code': '1f340', 'aliases': ['four_leaf_clover']}), - ("luggage", {'code': '1f9f3', 'aliases': ['packing', 'travel']}), - ("lungs", {'code': '1fac1', 'aliases': ['breath', 'exhalation', 'inhalation', 'respiration']}), - ("lying", {'code': '1f925', 'aliases': []}), - ("mage", {'code': '1f9d9', 'aliases': []}), - ("magic_wand", {'code': '1fa84', 'aliases': ['magic']}), - ("magnet", {'code': '1f9f2', 'aliases': ['attraction', 'horseshoe']}), - ("magnifying_glass_tilted_right", {'code': '1f50e', 'aliases': ['magnifying']}), - ("mahjong", {'code': '1f004', 'aliases': []}), - ("mail_dropoff", {'code': '1f4ee', 'aliases': []}), - ("mail_received", {'code': '1f4e8', 'aliases': []}), - ("mail_sent", {'code': '1f4e9', 'aliases': ['sealed']}), - ("mailbox", {'code': '1f4eb', 'aliases': []}), - ("male_sign", {'code': '2642', 'aliases': []}), - ("mammoth", {'code': '1f9a3', 'aliases': ['tusk', 'woolly']}), - ("man", {'code': '1f468', 'aliases': []}), - ("man_and_woman_holding_hands", {'code': '1f46b', 'aliases': ['man_and_woman_couple']}), - ("man_artist", {'code': '1f468-200d-1f3a8', 'aliases': []}), - ("man_astronaut", {'code': '1f468-200d-1f680', 'aliases': []}), - ("man_bald", {'code': '1f468-200d-1f9b2', 'aliases': []}), - ("man_beard", {'code': '1f9d4-200d-2642', 'aliases': []}), - ("man_biking", {'code': '1f6b4-200d-2642', 'aliases': []}), - ("man_blond_hair", {'code': '1f471-200d-2642', 'aliases': ['blond_haired_man']}), - ("man_bouncing_ball", {'code': '26f9-fe0f-200d-2642-fe0f', 'aliases': []}), - ("man_bowing", {'code': '1f647-200d-2642', 'aliases': []}), - ("man_cartwheeling", {'code': '1f938-200d-2642', 'aliases': []}), - ("man_climbing", {'code': '1f9d7-200d-2642', 'aliases': []}), - ("man_construction_worker", {'code': '1f477-200d-2642', 'aliases': []}), - ("man_cook", {'code': '1f468-200d-1f373', 'aliases': []}), - ("man_curly_hair", {'code': '1f468-200d-1f9b1', 'aliases': []}), - ("man_detective", {'code': '1f575-fe0f-200d-2642-fe0f', 'aliases': []}), - ("man_elf", {'code': '1f9dd-200d-2642', 'aliases': []}), - ("man_facepalming", {'code': '1f926-200d-2642', 'aliases': []}), - ("man_factory_worker", {'code': '1f468-200d-1f3ed', 'aliases': []}), - ("man_fairy", {'code': '1f9da-200d-2642', 'aliases': []}), - ("man_farmer", {'code': '1f468-200d-1f33e', 'aliases': []}), - ("man_feeding_baby", {'code': '1f468-200d-1f37c', 'aliases': []}), - ("man_firefighter", {'code': '1f468-200d-1f692', 'aliases': []}), - ("man_frowning", {'code': '1f64d-200d-2642', 'aliases': []}), - ("man_genie", {'code': '1f9de-200d-2642', 'aliases': []}), - ("man_gesturing_no", {'code': '1f645-200d-2642', 'aliases': []}), - ("man_gesturing_ok", {'code': '1f646-200d-2642', 'aliases': []}), - ("man_getting_haircut", {'code': '1f487-200d-2642', 'aliases': []}), - ("man_getting_massage", {'code': '1f486-200d-2642', 'aliases': []}), - ("man_golfing", {'code': '1f3cc-fe0f-200d-2642-fe0f', 'aliases': []}), - ("man_guard", {'code': '1f482-200d-2642', 'aliases': []}), - ("man_health_worker", {'code': '1f468-200d-2695', 'aliases': []}), - ("man_in_lotus_position", {'code': '1f9d8-200d-2642', 'aliases': []}), - ("man_in_manual_wheelchair", {'code': '1f468-200d-1f9bd', 'aliases': []}), - ("man_in_motorized_wheelchair", {'code': '1f468-200d-1f9bc', 'aliases': []}), - ("man_in_steamy_room", {'code': '1f9d6-200d-2642', 'aliases': []}), - ("man_in_tuxedo", {'code': '1f935-200d-2642', 'aliases': []}), - ("man_judge", {'code': '1f468-200d-2696', 'aliases': []}), - ("man_juggling", {'code': '1f939-200d-2642', 'aliases': []}), - ("man_kneeling", {'code': '1f9ce-200d-2642', 'aliases': []}), - ("man_lifting_weights", {'code': '1f3cb-fe0f-200d-2642-fe0f', 'aliases': []}), - ("man_mage", {'code': '1f9d9-200d-2642', 'aliases': []}), - ("man_mechanic", {'code': '1f468-200d-1f527', 'aliases': []}), - ("man_mountain_biking", {'code': '1f6b5-200d-2642', 'aliases': []}), - ("man_office_worker", {'code': '1f468-200d-1f4bc', 'aliases': []}), - ("man_pilot", {'code': '1f468-200d-2708', 'aliases': []}), - ("man_playing_handball", {'code': '1f93e-200d-2642', 'aliases': []}), - ("man_playing_water_polo", {'code': '1f93d-200d-2642', 'aliases': []}), - ("man_police_officer", {'code': '1f46e-200d-2642', 'aliases': []}), - ("man_pouting", {'code': '1f64e-200d-2642', 'aliases': []}), - ("man_raising_hand", {'code': '1f64b-200d-2642', 'aliases': []}), - ("man_red_hair", {'code': '1f468-200d-1f9b0', 'aliases': []}), - ("man_rowing_boat", {'code': '1f6a3-200d-2642', 'aliases': []}), - ("man_running", {'code': '1f3c3-200d-2642', 'aliases': []}), - ("man_scientist", {'code': '1f468-200d-1f52c', 'aliases': []}), - ("man_shrugging", {'code': '1f937-200d-2642', 'aliases': []}), - ("man_singer", {'code': '1f468-200d-1f3a4', 'aliases': []}), - ("man_standing", {'code': '1f9cd-200d-2642', 'aliases': []}), - ("man_student", {'code': '1f468-200d-1f393', 'aliases': []}), - ("man_superhero", {'code': '1f9b8-200d-2642', 'aliases': []}), - ("man_supervillain", {'code': '1f9b9-200d-2642', 'aliases': []}), - ("man_surfing", {'code': '1f3c4-200d-2642', 'aliases': []}), - ("man_swimming", {'code': '1f3ca-200d-2642', 'aliases': []}), - ("man_teacher", {'code': '1f468-200d-1f3eb', 'aliases': []}), - ("man_technologist", {'code': '1f468-200d-1f4bb', 'aliases': []}), - ("man_tipping_hand", {'code': '1f481-200d-2642', 'aliases': []}), - ("man_vampire", {'code': '1f9db-200d-2642', 'aliases': []}), - ("man_walking", {'code': '1f6b6-200d-2642', 'aliases': []}), - ("man_wearing_turban", {'code': '1f473-200d-2642', 'aliases': []}), - ("man_white_hair", {'code': '1f468-200d-1f9b3', 'aliases': []}), - ("man_with_veil", {'code': '1f470-200d-2642', 'aliases': []}), - ("man_with_white_cane", {'code': '1f468-200d-1f9af', 'aliases': []}), - ("man_zombie", {'code': '1f9df-200d-2642', 'aliases': []}), - ("mango", {'code': '1f96d', 'aliases': ['fruit']}), - ("mantelpiece_clock", {'code': '1f570', 'aliases': []}), - ("manual_wheelchair", {'code': '1f9bd', 'aliases': []}), - ("map", {'code': '1f5fa', 'aliases': ['world_map', 'road_trip']}), - ("maple_leaf", {'code': '1f341', 'aliases': []}), - ("mask", {'code': '1f637', 'aliases': []}), - ("massage", {'code': '1f486', 'aliases': []}), - ("mate", {'code': '1f9c9', 'aliases': []}), - ("meat", {'code': '1f356', 'aliases': []}), - ("mechanic", {'code': '1f9d1-200d-1f527', 'aliases': []}), - ("mechanical_arm", {'code': '1f9be', 'aliases': []}), - ("mechanical_leg", {'code': '1f9bf', 'aliases': []}), - ("medal", {'code': '1f3c5', 'aliases': []}), - ("medical_symbol", {'code': '2695', 'aliases': ['aesculapius', 'staff']}), - ("medicine", {'code': '1f48a', 'aliases': ['pill']}), - ("megaphone", {'code': '1f4e3', 'aliases': ['shout']}), - ("melon", {'code': '1f348', 'aliases': []}), - ("melting_face", {'code': '1fae0', 'aliases': ['dissolve', 'liquid', 'melt']}), - ("memo", {'code': '1f4dd', 'aliases': ['note']}), - ("men_with_bunny_ears", {'code': '1f46f-200d-2642', 'aliases': []}), - ("men_wrestling", {'code': '1f93c-200d-2642', 'aliases': []}), - ("mending_heart", {'code': '2764-200d-1fa79', 'aliases': ['healthier', 'improving', 'mending', 'recovering', 'recuperating', 'well']}), - ("menorah", {'code': '1f54e', 'aliases': []}), - ("mens", {'code': '1f6b9', 'aliases': []}), - ("mermaid", {'code': '1f9dc-200d-2640', 'aliases': []}), - ("merman", {'code': '1f9dc-200d-2642', 'aliases': ['triton']}), - ("merperson", {'code': '1f9dc', 'aliases': []}), - ("metro", {'code': '24c2', 'aliases': ['m']}), - ("microbe", {'code': '1f9a0', 'aliases': ['amoeba']}), - ("microphone", {'code': '1f3a4', 'aliases': ['mike', 'mic']}), - ("middle_finger", {'code': '1f595', 'aliases': []}), - ("military_helmet", {'code': '1fa96', 'aliases': ['army', 'military', 'soldier', 'warrior']}), - ("military_medal", {'code': '1f396', 'aliases': []}), - ("milk", {'code': '1f95b', 'aliases': ['glass_of_milk']}), - ("milky_way", {'code': '1f30c', 'aliases': ['night_sky']}), - ("mine", {'code': '26cf', 'aliases': ['pick']}), - ("minibus", {'code': '1f690', 'aliases': []}), - ("minus", {'code': '2796', 'aliases': ['subtract']}), - ("mirror", {'code': '1fa9e', 'aliases': ['reflection', 'reflector', 'speculum']}), - ("mirror_ball", {'code': '1faa9', 'aliases': ['glitter']}), - ("mobile_phone", {'code': '1f4f1', 'aliases': ['smartphone', 'iphone', 'android']}), - ("money", {'code': '1f4b0', 'aliases': []}), - ("money_face", {'code': '1f911', 'aliases': ['kaching']}), - ("monkey", {'code': '1f412', 'aliases': []}), - ("monkey_face", {'code': '1f435', 'aliases': []}), - ("monorail", {'code': '1f69d', 'aliases': ['elevated_train']}), - ("moon", {'code': '1f319', 'aliases': []}), - ("moon_cake", {'code': '1f96e', 'aliases': ['autumn', 'festival', 'yuèbǐng', 'yuebing']}), - ("moon_ceremony", {'code': '1f391', 'aliases': []}), - ("moon_face", {'code': '1f31d', 'aliases': []}), - ("mosque", {'code': '1f54c', 'aliases': []}), - ("mosquito", {'code': '1f99f', 'aliases': ['malaria']}), - ("mostly_sunny", {'code': '1f324', 'aliases': []}), - ("mother_christmas", {'code': '1f936', 'aliases': ['mrs_claus']}), - ("motor_boat", {'code': '1f6e5', 'aliases': []}), - ("motorcycle", {'code': '1f3cd', 'aliases': []}), - ("motorized_wheelchair", {'code': '1f9bc', 'aliases': []}), - ("mount_fuji", {'code': '1f5fb', 'aliases': []}), - ("mountain", {'code': '26f0', 'aliases': []}), - ("mountain_biker", {'code': '1f6b5', 'aliases': []}), - ("mountain_railway", {'code': '1f69e', 'aliases': []}), - ("mountain_sunrise", {'code': '1f304', 'aliases': []}), - ("mouse", {'code': '1f401', 'aliases': []}), - ("mouse_trap", {'code': '1faa4', 'aliases': ['bait', 'mousetrap', 'snare', 'trap']}), - ("movie_camera", {'code': '1f3a5', 'aliases': []}), - ("moving_truck", {'code': '1f69a', 'aliases': []}), - ("multiplication", {'code': '2716', 'aliases': ['multiply']}), - ("muscle", {'code': '1f4aa', 'aliases': []}), - ("mushroom", {'code': '1f344', 'aliases': []}), - ("music", {'code': '1f3b5', 'aliases': []}), - ("musical_notes", {'code': '1f3b6', 'aliases': []}), - ("musical_score", {'code': '1f3bc', 'aliases': []}), - ("mute", {'code': '1f507', 'aliases': ['no_sound']}), - ("mute_notifications", {'code': '1f515', 'aliases': []}), - ("mx_claus", {'code': '1f9d1-200d-1f384', 'aliases': ['claus_christmas']}), - ("nail_polish", {'code': '1f485', 'aliases': ['nail_care']}), - ("name_badge", {'code': '1f4db', 'aliases': []}), - ("naruto", {'code': '1f365', 'aliases': []}), - ("national_park", {'code': '1f3de', 'aliases': []}), - ("nauseated", {'code': '1f922', 'aliases': ['queasy']}), - ("nazar_amulet", {'code': '1f9ff', 'aliases': ['bead', 'charm', 'evil_eye', 'nazar', 'talisman']}), - ("nerd", {'code': '1f913', 'aliases': ['geek']}), - ("nest_with_eggs", {'code': '1faba', 'aliases': []}), - ("nesting_dolls", {'code': '1fa86', 'aliases': ['doll', 'russia']}), - ("neutral", {'code': '1f610', 'aliases': []}), - ("new", {'code': '1f195', 'aliases': []}), - ("new_baby", {'code': '1f425', 'aliases': []}), - ("new_moon", {'code': '1f311', 'aliases': []}), - ("new_moon_face", {'code': '1f31a', 'aliases': []}), - ("newspaper", {'code': '1f5de', 'aliases': ['swat']}), - ("next_track", {'code': '23ed', 'aliases': ['skip_forward']}), - ("ng", {'code': '1f196', 'aliases': []}), - ("night", {'code': '1f303', 'aliases': []}), - ("nine", {'code': '0039-20e3', 'aliases': []}), - ("ninja", {'code': '1f977', 'aliases': ['fighter', 'hidden', 'stealth']}), - ("no_bicycles", {'code': '1f6b3', 'aliases': []}), - ("no_entry", {'code': '26d4', 'aliases': ['wrong_way']}), - ("no_pedestrians", {'code': '1f6b7', 'aliases': []}), - ("no_phones", {'code': '1f4f5', 'aliases': []}), - ("no_signal", {'code': '1f645', 'aliases': ['nope']}), - ("no_smoking", {'code': '1f6ad', 'aliases': []}), - ("non-potable_water", {'code': '1f6b1', 'aliases': []}), - ("nose", {'code': '1f443', 'aliases': []}), - ("notebook", {'code': '1f4d3', 'aliases': ['composition_book']}), - ("notifications", {'code': '1f514', 'aliases': ['bell']}), - ("nut_and_bolt", {'code': '1f529', 'aliases': ['screw']}), - ("o", {'code': '1f17e', 'aliases': []}), - ("ocean", {'code': '1f30a', 'aliases': []}), - ("octopus", {'code': '1f419', 'aliases': []}), - ("oden", {'code': '1f362', 'aliases': []}), - ("office", {'code': '1f3e2', 'aliases': []}), - ("office_supplies", {'code': '1f587', 'aliases': ['paperclip_chain', 'linked']}), - ("office_worker", {'code': '1f9d1-200d-1f4bc', 'aliases': []}), - ("ogre", {'code': '1f479', 'aliases': []}), - ("oh_no", {'code': '1f615', 'aliases': ['half_frown', 'concerned', 'confused']}), - ("oil_drum", {'code': '1f6e2', 'aliases': ['commodities']}), - ("ok", {'code': '1f44c', 'aliases': ['got_it']}), - ("ok_signal", {'code': '1f646', 'aliases': []}), - ("older_man", {'code': '1f474', 'aliases': ['elderly_man']}), - ("older_person", {'code': '1f9d3', 'aliases': ['old']}), - ("older_woman", {'code': '1f475', 'aliases': ['elderly_woman']}), - ("olive", {'code': '1fad2', 'aliases': []}), - ("om", {'code': '1f549', 'aliases': ['hinduism']}), - ("on", {'code': '1f51b', 'aliases': []}), - ("oncoming_bus", {'code': '1f68d', 'aliases': []}), - ("oncoming_car", {'code': '1f698', 'aliases': ['oncoming_automobile']}), - ("oncoming_police_car", {'code': '1f694', 'aliases': []}), - ("oncoming_taxi", {'code': '1f696', 'aliases': []}), - ("oncoming_train", {'code': '1f686', 'aliases': []}), - ("oncoming_tram", {'code': '1f68a', 'aliases': ['oncoming_streetcar', 'oncoming_trolley']}), - ("one", {'code': '0031-20e3', 'aliases': []}), - ("one_piece_swimsuit", {'code': '1fa71', 'aliases': []}), - ("onigiri", {'code': '1f359', 'aliases': []}), - ("onion", {'code': '1f9c5', 'aliases': []}), - ("open_hands", {'code': '1f450', 'aliases': []}), - ("open_mouth", {'code': '1f62e', 'aliases': ['surprise']}), - ("ophiuchus", {'code': '26ce', 'aliases': []}), - ("orange", {'code': '1f34a', 'aliases': ['tangerine', 'mandarin']}), - ("orange_book", {'code': '1f4d9', 'aliases': []}), - ("orange_circle", {'code': '1f7e0', 'aliases': []}), - ("orange_heart", {'code': '1f9e1', 'aliases': []}), - ("orange_square", {'code': '1f7e7', 'aliases': []}), - ("orangutan", {'code': '1f9a7', 'aliases': ['ape']}), - ("organize", {'code': '1f4c1', 'aliases': ['file_folder']}), - ("orthodox_cross", {'code': '2626', 'aliases': []}), - ("otter", {'code': '1f9a6', 'aliases': ['playful']}), - ("outbox", {'code': '1f4e4', 'aliases': []}), - ("owl", {'code': '1f989', 'aliases': []}), - ("ox", {'code': '1f402', 'aliases': ['bull']}), - ("oyster", {'code': '1f9aa', 'aliases': []}), - ("package", {'code': '1f4e6', 'aliases': []}), - ("paella", {'code': '1f958', 'aliases': []}), - ("page_with_curl", {'code': '1f4c3', 'aliases': ['curl']}), - ("pager", {'code': '1f4df', 'aliases': []}), - ("paintbrush", {'code': '1f58c', 'aliases': []}), - ("palm_down_hand", {'code': '1faf3', 'aliases': ['dismiss', 'shoo']}), - ("palm_tree", {'code': '1f334', 'aliases': []}), - ("palm_up_hand", {'code': '1faf4', 'aliases': ['beckon', 'come', 'offer']}), - ("palms_up_together", {'code': '1f932', 'aliases': ['prayer']}), - ("pancakes", {'code': '1f95e', 'aliases': ['breakfast']}), - ("panda", {'code': '1f43c', 'aliases': []}), - ("paperclip", {'code': '1f4ce', 'aliases': ['attachment']}), - ("parachute", {'code': '1fa82', 'aliases': ['hang_glide', 'parasail', 'skydive']}), - ("parking", {'code': '1f17f', 'aliases': ['p']}), - ("parrot", {'code': '1f99c', 'aliases': ['talk']}), - ("part_alternation", {'code': '303d', 'aliases': []}), - ("partly_sunny", {'code': '26c5', 'aliases': ['partly_cloudy']}), - ("partying_face", {'code': '1f973', 'aliases': []}), - ("pass", {'code': '1f3ab', 'aliases': []}), - ("passenger_ship", {'code': '1f6f3', 'aliases': ['yacht', 'cruise']}), - ("passport_control", {'code': '1f6c2', 'aliases': ['immigration']}), - ("pause", {'code': '23f8', 'aliases': []}), - ("paw_prints", {'code': '1f43e', 'aliases': ['paws']}), - ("peace", {'code': '262e', 'aliases': []}), - ("peace_sign", {'code': '270c', 'aliases': ['victory']}), - ("peach", {'code': '1f351', 'aliases': []}), - ("peacock", {'code': '1f99a', 'aliases': ['ostentatious', 'peahen']}), - ("peanuts", {'code': '1f95c', 'aliases': []}), - ("pear", {'code': '1f350', 'aliases': []}), - ("pen", {'code': '1f58a', 'aliases': ['ballpoint_pen']}), - ("pencil", {'code': '270f', 'aliases': []}), - ("penguin", {'code': '1f427', 'aliases': []}), - ("pensive", {'code': '1f614', 'aliases': ['tired']}), - ("people_holding_hands", {'code': '1f9d1-200d-1f91d-200d-1f9d1', 'aliases': ['hold', 'holding_hands']}), - ("people_hugging", {'code': '1fac2', 'aliases': ['goodbye', 'thanks']}), - ("performing_arts", {'code': '1f3ad', 'aliases': ['drama', 'theater']}), - ("persevere", {'code': '1f623', 'aliases': ['helpless']}), - ("person", {'code': '1f9d1', 'aliases': []}), - ("person_bald", {'code': '1f9d1-200d-1f9b2', 'aliases': []}), - ("person_beard", {'code': '1f9d4', 'aliases': []}), - ("person_blond_hair", {'code': '1f471', 'aliases': ['blond_haired_person']}), - ("person_climbing", {'code': '1f9d7', 'aliases': []}), - ("person_curly_hair", {'code': '1f9d1-200d-1f9b1', 'aliases': []}), - ("person_feeding_baby", {'code': '1f9d1-200d-1f37c', 'aliases': []}), - ("person_frowning", {'code': '1f64d', 'aliases': []}), - ("person_in_lotus_position", {'code': '1f9d8', 'aliases': []}), - ("person_in_manual_wheelchair", {'code': '1f9d1-200d-1f9bd', 'aliases': []}), - ("person_in_motorized_wheelchair", {'code': '1f9d1-200d-1f9bc', 'aliases': []}), - ("person_in_steamy_room", {'code': '1f9d6', 'aliases': []}), - ("person_kneeling", {'code': '1f9ce', 'aliases': ['kneel']}), - ("person_pouting", {'code': '1f64e', 'aliases': []}), - ("person_red_hair", {'code': '1f9d1-200d-1f9b0', 'aliases': []}), - ("person_standing", {'code': '1f9cd', 'aliases': ['stand']}), - ("person_white_hair", {'code': '1f9d1-200d-1f9b3', 'aliases': []}), - ("person_with_crown", {'code': '1fac5', 'aliases': ['monarch', 'noble', 'regal', 'royalty']}), - ("person_with_white_cane", {'code': '1f9d1-200d-1f9af', 'aliases': []}), - ("petri_dish", {'code': '1f9eb', 'aliases': ['biology', 'culture']}), - ("phone", {'code': '260e', 'aliases': ['telephone']}), - ("phone_off", {'code': '1f4f4', 'aliases': []}), - ("piano", {'code': '1f3b9', 'aliases': ['musical_keyboard']}), - ("pickup_truck", {'code': '1f6fb', 'aliases': ['pick_up', 'pickup']}), - ("picture", {'code': '1f5bc', 'aliases': ['framed_picture']}), - ("pie", {'code': '1f967', 'aliases': ['filling', 'pastry']}), - ("pig", {'code': '1f416', 'aliases': ['oink']}), - ("pig_nose", {'code': '1f43d', 'aliases': []}), - ("piglet", {'code': '1f437', 'aliases': []}), - ("pilot", {'code': '1f9d1-200d-2708', 'aliases': []}), - ("pin", {'code': '1f4cd', 'aliases': ['sewing_pin']}), - ("pinched_fingers", {'code': '1f90c', 'aliases': ['fingers', 'hand_gesture', 'interrogation', 'pinched', 'sarcastic']}), - ("pinching_hand", {'code': '1f90f', 'aliases': ['small_amount']}), - ("pineapple", {'code': '1f34d', 'aliases': []}), - ("ping_pong", {'code': '1f3d3', 'aliases': ['table_tennis']}), - ("pirate_flag", {'code': '1f3f4-200d-2620', 'aliases': ['jolly_roger', 'plunder']}), - ("pisces", {'code': '2653', 'aliases': []}), - ("pizza", {'code': '1f355', 'aliases': []}), - ("piñata", {'code': '1fa85', 'aliases': ['pinata']}), - ("placard", {'code': '1faa7', 'aliases': ['demonstration', 'picket', 'protest', 'sign']}), - ("place_holder", {'code': '1f4d1', 'aliases': []}), - ("place_of_worship", {'code': '1f6d0', 'aliases': []}), - ("play", {'code': '25b6', 'aliases': []}), - ("play_pause", {'code': '23ef', 'aliases': []}), - ("play_reverse", {'code': '25c0', 'aliases': []}), - ("playground_slide", {'code': '1f6dd', 'aliases': ['amusement_park']}), - ("playing_cards", {'code': '1f3b4', 'aliases': []}), - ("pleading_face", {'code': '1f97a', 'aliases': ['begging', 'mercy', 'puppy_eyes']}), - ("plunger", {'code': '1faa0', 'aliases': ['force_cup', 'suction']}), - ("plus", {'code': '2795', 'aliases': ['add']}), - ("point_down", {'code': '1f447', 'aliases': []}), - ("point_left", {'code': '1f448', 'aliases': []}), - ("point_right", {'code': '1f449', 'aliases': []}), - ("point_up", {'code': '1f446', 'aliases': ['this']}), - ("polar_bear", {'code': '1f43b-200d-2744', 'aliases': ['arctic']}), - ("police", {'code': '1f46e', 'aliases': ['cop']}), - ("police_car", {'code': '1f693', 'aliases': []}), - ("pony", {'code': '1f434', 'aliases': []}), - ("poodle", {'code': '1f429', 'aliases': []}), - ("poop", {'code': '1f4a9', 'aliases': ['pile_of_poo']}), - ("popcorn", {'code': '1f37f', 'aliases': []}), - ("post_office", {'code': '1f3e4', 'aliases': []}), - ("potable_water", {'code': '1f6b0', 'aliases': ['tap_water', 'drinking_water']}), - ("potato", {'code': '1f954', 'aliases': []}), - ("potted_plant", {'code': '1fab4', 'aliases': ['boring', 'grow', 'nurturing', 'useless']}), - ("pouch", {'code': '1f45d', 'aliases': []}), - ("pound_notes", {'code': '1f4b7', 'aliases': []}), - ("pouring_liquid", {'code': '1fad7', 'aliases': ['spill']}), - ("pray", {'code': '1f64f', 'aliases': ['welcome', 'thank_you', 'namaste']}), - ("prayer_beads", {'code': '1f4ff', 'aliases': []}), - ("pregnant", {'code': '1f930', 'aliases': ['expecting']}), - ("pregnant_man", {'code': '1fac3', 'aliases': []}), - ("pregnant_person", {'code': '1fac4', 'aliases': []}), - ("pretzel", {'code': '1f968', 'aliases': ['twisted']}), - ("previous_track", {'code': '23ee', 'aliases': ['skip_back']}), - ("prince", {'code': '1f934', 'aliases': []}), - ("princess", {'code': '1f478', 'aliases': []}), - ("printer", {'code': '1f5a8', 'aliases': []}), - ("privacy", {'code': '1f50f', 'aliases': ['key_signing', 'digital_security', 'protected']}), - ("prohibited", {'code': '1f6ab', 'aliases': ['not_allowed']}), - ("projector", {'code': '1f4fd', 'aliases': ['movie']}), - ("puppy", {'code': '1f436', 'aliases': []}), - ("purple_circle", {'code': '1f7e3', 'aliases': []}), - ("purple_heart", {'code': '1f49c', 'aliases': ['bravery']}), - ("purple_square", {'code': '1f7ea', 'aliases': []}), - ("purse", {'code': '1f45b', 'aliases': []}), - ("push_pin", {'code': '1f4cc', 'aliases': ['thumb_tack']}), - ("put_litter_in_its_place", {'code': '1f6ae', 'aliases': []}), - ("puzzle_piece", {'code': '1f9e9', 'aliases': ['interlocking', 'jigsaw', 'piece', 'puzzle']}), - ("question", {'code': '2753', 'aliases': []}), - ("rabbit", {'code': '1f407', 'aliases': []}), - ("raccoon", {'code': '1f99d', 'aliases': ['curious', 'sly']}), - ("racecar", {'code': '1f3ce', 'aliases': []}), - ("radio", {'code': '1f4fb', 'aliases': []}), - ("radio_button", {'code': '1f518', 'aliases': []}), - ("radioactive", {'code': '2622', 'aliases': ['nuclear']}), - ("rage", {'code': '1f621', 'aliases': ['mad', 'grumpy', 'very_angry']}), - ("railway_car", {'code': '1f683', 'aliases': ['train_car']}), - ("railway_track", {'code': '1f6e4', 'aliases': ['train_tracks']}), - ("rainbow", {'code': '1f308', 'aliases': ['pride', 'lgbtq']}), - ("rainbow_flag", {'code': '1f3f3-200d-1f308', 'aliases': []}), - ("rainy", {'code': '1f327', 'aliases': ['soaked', 'drenched']}), - ("raised_hands", {'code': '1f64c', 'aliases': ['praise']}), - ("raising_hand", {'code': '1f64b', 'aliases': ['pick_me']}), - ("ram", {'code': '1f40f', 'aliases': []}), - ("ramen", {'code': '1f35c', 'aliases': ['noodles']}), - ("rat", {'code': '1f400', 'aliases': []}), - ("razor", {'code': '1fa92', 'aliases': ['sharp', 'shave']}), - ("receipt", {'code': '1f9fe', 'aliases': ['accounting', 'bookkeeping', 'evidence', 'proof']}), - ("record", {'code': '23fa', 'aliases': []}), - ("recreational_vehicle", {'code': '1f699', 'aliases': ['jeep']}), - ("recycle", {'code': '267b', 'aliases': []}), - ("red_book", {'code': '1f4d5', 'aliases': ['closed_book']}), - ("red_circle", {'code': '1f534', 'aliases': []}), - ("red_envelope", {'code': '1f9e7', 'aliases': ['good_luck', 'hóngbāo', 'lai_see', 'hongbao']}), - ("red_square", {'code': '1f7e5', 'aliases': ['red']}), - ("red_triangle_down", {'code': '1f53b', 'aliases': []}), - ("red_triangle_up", {'code': '1f53a', 'aliases': []}), - ("registered", {'code': '00ae', 'aliases': ['r']}), - ("relieved", {'code': '1f60c', 'aliases': []}), - ("reminder_ribbon", {'code': '1f397', 'aliases': []}), - ("repeat", {'code': '1f501', 'aliases': []}), - ("repeat_one", {'code': '1f502', 'aliases': []}), - ("reply", {'code': '21a9', 'aliases': ['left_hook']}), - ("restroom", {'code': '1f6bb', 'aliases': []}), - ("revolving_hearts", {'code': '1f49e', 'aliases': []}), - ("rewind", {'code': '23ea', 'aliases': ['fast_reverse']}), - ("rhinoceros", {'code': '1f98f', 'aliases': []}), - ("ribbon", {'code': '1f380', 'aliases': ['decoration']}), - ("rice", {'code': '1f35a', 'aliases': []}), - ("right", {'code': '27a1', 'aliases': ['east']}), - ("right_fist", {'code': '1f91c', 'aliases': []}), - ("rightwards_hand", {'code': '1faf1', 'aliases': ['rightward']}), - ("ring", {'code': '1f48d', 'aliases': []}), - ("ring_buoy", {'code': '1f6df', 'aliases': ['float', 'life_preserver', 'life_saver', 'rescue']}), - ("ringed_planet", {'code': '1fa90', 'aliases': ['saturn', 'saturnine']}), - ("road", {'code': '1f6e3', 'aliases': ['motorway']}), - ("robot", {'code': '1f916', 'aliases': []}), - ("rock", {'code': '1faa8', 'aliases': ['boulder', 'heavy', 'solid', 'stone']}), - ("rock_carving", {'code': '1f5ff', 'aliases': ['moyai']}), - ("rock_on", {'code': '1f918', 'aliases': ['sign_of_the_horns']}), - ("rocket", {'code': '1f680', 'aliases': []}), - ("roll_of_paper", {'code': '1f9fb', 'aliases': ['paper_towels', 'toilet_paper']}), - ("roller_coaster", {'code': '1f3a2', 'aliases': []}), - ("roller_skate", {'code': '1f6fc', 'aliases': ['roller', 'skate']}), - ("rolling_eyes", {'code': '1f644', 'aliases': []}), - ("rolling_on_the_floor_laughing", {'code': '1f923', 'aliases': ['rofl']}), - ("rolodex", {'code': '1f4c7', 'aliases': ['card_index']}), - ("rooster", {'code': '1f413', 'aliases': ['alarm', 'cock-a-doodle-doo']}), - ("rose", {'code': '1f339', 'aliases': []}), - ("rosette", {'code': '1f3f5', 'aliases': []}), - ("rowboat", {'code': '1f6a3', 'aliases': ['crew', 'sculling', 'rowing']}), - ("rugby", {'code': '1f3c9', 'aliases': []}), - ("ruler", {'code': '1f4cf', 'aliases': ['straightedge']}), - ("running", {'code': '1f3c3', 'aliases': ['runner']}), - ("running_shirt", {'code': '1f3bd', 'aliases': []}), - ("sad", {'code': '2639', 'aliases': ['big_frown']}), - ("safety_pin", {'code': '1f9f7', 'aliases': ['diaper', 'punk_rock']}), - ("safety_vest", {'code': '1f9ba', 'aliases': ['emergency', 'vest']}), - ("sagittarius", {'code': '2650', 'aliases': []}), - ("sake", {'code': '1f376', 'aliases': []}), - ("salad", {'code': '1f957', 'aliases': []}), - ("salt", {'code': '1f9c2', 'aliases': ['shaker']}), - ("saluting_face", {'code': '1fae1', 'aliases': ['salute', 'troops', 'yes']}), - ("sandal", {'code': '1f461', 'aliases': ['flip_flops']}), - ("sandwich", {'code': '1f96a', 'aliases': []}), - ("santa", {'code': '1f385', 'aliases': []}), - ("sari", {'code': '1f97b', 'aliases': []}), - ("satellite", {'code': '1f6f0', 'aliases': []}), - ("satellite_antenna", {'code': '1f4e1', 'aliases': []}), - ("sauropod", {'code': '1f995', 'aliases': ['brachiosaurus', 'brontosaurus', 'diplodocus']}), - ("saxophone", {'code': '1f3b7', 'aliases': []}), - ("scarf", {'code': '1f9e3', 'aliases': ['neck']}), - ("school", {'code': '1f3eb', 'aliases': []}), - ("science", {'code': '1f52c', 'aliases': ['microscope']}), - ("scientist", {'code': '1f9d1-200d-1f52c', 'aliases': []}), - ("scissors", {'code': '2702', 'aliases': []}), - ("scooter", {'code': '1f6f5', 'aliases': ['motor_bike']}), - ("scorpion", {'code': '1f982', 'aliases': []}), - ("scorpius", {'code': '264f', 'aliases': []}), - ("scream", {'code': '1f631', 'aliases': []}), - ("scream_cat", {'code': '1f640', 'aliases': ['weary_cat']}), - ("screwdriver", {'code': '1fa9b', 'aliases': []}), - ("scroll", {'code': '1f4dc', 'aliases': []}), - ("seal", {'code': '1f9ad', 'aliases': ['sea_lion']}), - ("search", {'code': '1f50d', 'aliases': ['find', 'magnifying_glass']}), - ("seat", {'code': '1f4ba', 'aliases': []}), - ("second_place", {'code': '1f948', 'aliases': ['silver']}), - ("secret", {'code': '1f5dd', 'aliases': ['dungeon', 'old_key', 'encrypted', 'clue', 'hint']}), - ("secure", {'code': '1f510', 'aliases': ['lock_with_key', 'safe', 'commitment', 'loyalty']}), - ("see_no_evil", {'code': '1f648', 'aliases': []}), - ("seedling", {'code': '1f331', 'aliases': ['sprout']}), - ("seeing_stars", {'code': '1f4ab', 'aliases': []}), - ("selfie", {'code': '1f933', 'aliases': []}), - ("senbei", {'code': '1f358', 'aliases': ['rice_cracker']}), - ("service_dog", {'code': '1f415-200d-1f9ba', 'aliases': ['assistance', 'service']}), - ("seven", {'code': '0037-20e3', 'aliases': []}), - ("sewing_needle", {'code': '1faa1', 'aliases': ['embroidery', 'stitches', 'sutures', 'tailoring']}), - ("shamrock", {'code': '2618', 'aliases': ['clover']}), - ("shark", {'code': '1f988', 'aliases': []}), - ("shaved_ice", {'code': '1f367', 'aliases': []}), - ("sheep", {'code': '1f411', 'aliases': ['baa']}), - ("shell", {'code': '1f41a', 'aliases': ['seashell', 'conch', 'spiral_shell']}), - ("shield", {'code': '1f6e1', 'aliases': []}), - ("shinto_shrine", {'code': '26e9', 'aliases': []}), - ("ship", {'code': '1f6a2', 'aliases': []}), - ("shiro", {'code': '1f3ef', 'aliases': []}), - ("shirt", {'code': '1f455', 'aliases': ['tshirt']}), - ("shoe", {'code': '1f45e', 'aliases': []}), - ("shooting_star", {'code': '1f320', 'aliases': ['wish']}), - ("shopping_bags", {'code': '1f6cd', 'aliases': []}), - ("shopping_cart", {'code': '1f6d2', 'aliases': ['shopping_trolley']}), - ("shorts", {'code': '1fa73', 'aliases': ['pants']}), - ("shower", {'code': '1f6bf', 'aliases': []}), - ("shrimp", {'code': '1f990', 'aliases': []}), - ("shrug", {'code': '1f937', 'aliases': []}), - ("shuffle", {'code': '1f500', 'aliases': []}), - ("shushing_face", {'code': '1f92b', 'aliases': ['shush']}), - ("sick", {'code': '1f912', 'aliases': ['flu', 'face_with_thermometer', 'ill', 'fever']}), - ("silence", {'code': '1f910', 'aliases': ['quiet', 'hush', 'zip_it', 'lips_are_sealed']}), - ("silhouette", {'code': '1f464', 'aliases': ['shadow']}), - ("silhouettes", {'code': '1f465', 'aliases': ['shadows']}), - ("singer", {'code': '1f9d1-200d-1f3a4', 'aliases': []}), - ("siren", {'code': '1f6a8', 'aliases': ['rotating_light', 'alert']}), - ("six", {'code': '0036-20e3', 'aliases': []}), - ("skateboard", {'code': '1f6f9', 'aliases': ['board']}), - ("ski", {'code': '1f3bf', 'aliases': []}), - ("skier", {'code': '26f7', 'aliases': []}), - ("skull", {'code': '1f480', 'aliases': []}), - ("skull_and_crossbones", {'code': '2620', 'aliases': ['pirate', 'death', 'hazard', 'toxic', 'poison']}), - ("skunk", {'code': '1f9a8', 'aliases': ['stink']}), - ("sled", {'code': '1f6f7', 'aliases': ['sledge', 'sleigh']}), - ("sleeping", {'code': '1f634', 'aliases': []}), - ("sleepy", {'code': '1f62a', 'aliases': []}), - ("slot_machine", {'code': '1f3b0', 'aliases': []}), - ("sloth", {'code': '1f9a5', 'aliases': ['lazy', 'slow']}), - ("small_airplane", {'code': '1f6e9', 'aliases': []}), - ("small_blue_diamond", {'code': '1f539', 'aliases': []}), - ("small_glass", {'code': '1f943', 'aliases': []}), - ("small_orange_diamond", {'code': '1f538', 'aliases': []}), - ("smile", {'code': '1f642', 'aliases': []}), - ("smile_cat", {'code': '1f638', 'aliases': []}), - ("smiley", {'code': '1f603', 'aliases': []}), - ("smiley_cat", {'code': '1f63a', 'aliases': []}), - ("smiling_devil", {'code': '1f608', 'aliases': ['smiling_imp', 'smiling_face_with_horns']}), - ("smiling_face", {'code': '263a', 'aliases': ['relaxed']}), - ("smiling_face_with_hearts", {'code': '1f970', 'aliases': ['adore', 'crush']}), - ("smiling_face_with_tear", {'code': '1f972', 'aliases': ['grateful', 'smiling', 'tear', 'touched']}), - ("smirk", {'code': '1f60f', 'aliases': ['smug']}), - ("smirk_cat", {'code': '1f63c', 'aliases': ['smug_cat']}), - ("smoking", {'code': '1f6ac', 'aliases': []}), - ("snail", {'code': '1f40c', 'aliases': []}), - ("snake", {'code': '1f40d', 'aliases': ['hiss']}), - ("sneezing", {'code': '1f927', 'aliases': []}), - ("snowboarder", {'code': '1f3c2', 'aliases': []}), - ("snowflake", {'code': '2744', 'aliases': []}), - ("snowman", {'code': '2603', 'aliases': []}), - ("snowy", {'code': '1f328', 'aliases': ['snowstorm']}), - ("snowy_mountain", {'code': '1f3d4', 'aliases': []}), - ("soap", {'code': '1f9fc', 'aliases': ['bar', 'bathing', 'lather', 'soapdish']}), - ("sob", {'code': '1f62d', 'aliases': []}), - ("socks", {'code': '1f9e6', 'aliases': ['stocking']}), - ("soft_serve", {'code': '1f366', 'aliases': ['soft_ice_cream']}), - ("softball", {'code': '1f94e', 'aliases': ['glove', 'underarm']}), - ("softer", {'code': '1f509', 'aliases': []}), - ("soon", {'code': '1f51c', 'aliases': []}), - ("sort", {'code': '1f5c2', 'aliases': []}), - ("sos", {'code': '1f198', 'aliases': []}), - ("space_invader", {'code': '1f47e', 'aliases': []}), - ("spades", {'code': '2660', 'aliases': []}), - ("spaghetti", {'code': '1f35d', 'aliases': []}), - ("sparkle", {'code': '2747', 'aliases': []}), - ("sparkler", {'code': '1f387', 'aliases': []}), - ("sparkles", {'code': '2728', 'aliases': ['glamour']}), - ("sparkling_heart", {'code': '1f496', 'aliases': []}), - ("speak_no_evil", {'code': '1f64a', 'aliases': []}), - ("speaker", {'code': '1f508', 'aliases': []}), - ("speaking_head", {'code': '1f5e3', 'aliases': []}), - ("speech_bubble", {'code': '1f5e8', 'aliases': []}), - ("speechless", {'code': '1f636', 'aliases': ['no_mouth', 'blank', 'poker_face']}), - ("speedboat", {'code': '1f6a4', 'aliases': []}), - ("spider", {'code': '1f577', 'aliases': []}), - ("spiral_calendar", {'code': '1f5d3', 'aliases': ['pad']}), - ("spiral_notepad", {'code': '1f5d2', 'aliases': []}), - ("spock", {'code': '1f596', 'aliases': ['live_long_and_prosper']}), - ("sponge", {'code': '1f9fd', 'aliases': ['absorbing', 'porous']}), - ("spoon", {'code': '1f944', 'aliases': []}), - ("squared_ok", {'code': '1f197', 'aliases': []}), - ("squared_up", {'code': '1f199', 'aliases': []}), - ("squid", {'code': '1f991', 'aliases': []}), - ("stadium", {'code': '1f3df', 'aliases': []}), - ("star", {'code': '2b50', 'aliases': []}), - ("star_and_crescent", {'code': '262a', 'aliases': ['islam']}), - ("star_of_david", {'code': '2721', 'aliases': ['judaism']}), - ("star_struck", {'code': '1f929', 'aliases': []}), - ("station", {'code': '1f689', 'aliases': []}), - ("statue", {'code': '1f5fd', 'aliases': ['new_york', 'statue_of_liberty']}), - ("stethoscope", {'code': '1fa7a', 'aliases': []}), - ("stock_market", {'code': '1f4b9', 'aliases': []}), - ("stop", {'code': '1f91a', 'aliases': []}), - ("stop_button", {'code': '23f9', 'aliases': []}), - ("stop_sign", {'code': '1f6d1', 'aliases': ['octagonal_sign']}), - ("stopwatch", {'code': '23f1', 'aliases': []}), - ("strawberry", {'code': '1f353', 'aliases': []}), - ("strike", {'code': '1f3b3', 'aliases': ['bowling']}), - ("stuck_out_tongue", {'code': '1f61b', 'aliases': ['mischievous']}), - ("stuck_out_tongue_closed_eyes", {'code': '1f61d', 'aliases': []}), - ("stuck_out_tongue_wink", {'code': '1f61c', 'aliases': ['joking', 'crazy']}), - ("student", {'code': '1f9d1-200d-1f393', 'aliases': []}), - ("studio_microphone", {'code': '1f399', 'aliases': []}), - ("suburb", {'code': '1f3e1', 'aliases': []}), - ("subway", {'code': '1f687', 'aliases': []}), - ("sun_face", {'code': '1f31e', 'aliases': []}), - ("sunflower", {'code': '1f33b', 'aliases': []}), - ("sunglasses", {'code': '1f60e', 'aliases': []}), - ("sunny", {'code': '2600', 'aliases': []}), - ("sunrise", {'code': '1f305', 'aliases': ['ocean_sunrise']}), - ("sunset", {'code': '1f306', 'aliases': []}), - ("sunshowers", {'code': '1f326', 'aliases': ['sun_and_rain', 'partly_sunny_with_rain']}), - ("superhero", {'code': '1f9b8', 'aliases': []}), - ("supervillain", {'code': '1f9b9', 'aliases': []}), - ("surf", {'code': '1f3c4', 'aliases': []}), - ("sushi", {'code': '1f363', 'aliases': []}), - ("suspension_railway", {'code': '1f69f', 'aliases': []}), - ("swan", {'code': '1f9a2', 'aliases': ['cygnet', 'ugly_duckling']}), - ("sweat", {'code': '1f613', 'aliases': []}), - ("sweat_drops", {'code': '1f4a6', 'aliases': []}), - ("sweat_smile", {'code': '1f605', 'aliases': []}), - ("swim", {'code': '1f3ca', 'aliases': []}), - ("symbols", {'code': '1f523', 'aliases': []}), - ("synagogue", {'code': '1f54d', 'aliases': []}), - ("t_rex", {'code': '1f996', 'aliases': ['tyrannosaurus_rex']}), - ("taco", {'code': '1f32e', 'aliases': []}), - ("tada", {'code': '1f389', 'aliases': []}), - ("take_off", {'code': '1f6eb', 'aliases': ['departure', 'airplane_departure']}), - ("takeout_box", {'code': '1f961', 'aliases': ['oyster_pail']}), - ("taking_a_picture", {'code': '1f4f8', 'aliases': ['say_cheese']}), - ("tamale", {'code': '1fad4', 'aliases': ['mexican', 'wrapped']}), - ("taurus", {'code': '2649', 'aliases': []}), - ("taxi", {'code': '1f695', 'aliases': ['rideshare']}), - ("tea", {'code': '1f375', 'aliases': []}), - ("teacher", {'code': '1f9d1-200d-1f3eb', 'aliases': []}), - ("teapot", {'code': '1fad6', 'aliases': []}), - ("technologist", {'code': '1f9d1-200d-1f4bb', 'aliases': []}), - ("teddy_bear", {'code': '1f9f8', 'aliases': ['plaything', 'plush', 'stuffed']}), - ("telescope", {'code': '1f52d', 'aliases': []}), - ("temperature", {'code': '1f321', 'aliases': ['thermometer', 'warm']}), - ("tempura", {'code': '1f364', 'aliases': []}), - ("ten", {'code': '1f51f', 'aliases': []}), - ("tennis", {'code': '1f3be', 'aliases': []}), - ("tent", {'code': '26fa', 'aliases': ['camping']}), - ("test_tube", {'code': '1f9ea', 'aliases': ['chemistry']}), - ("thinking", {'code': '1f914', 'aliases': []}), - ("third_place", {'code': '1f949', 'aliases': ['bronze']}), - ("thong_sandal", {'code': '1fa74', 'aliases': ['beach_sandals', 'sandals', 'thong_sandals', 'thongs', 'zōri', 'zori']}), - ("thought", {'code': '1f4ad', 'aliases': ['dream']}), - ("thread", {'code': '1f9f5', 'aliases': ['spool', 'string']}), - ("three", {'code': '0033-20e3', 'aliases': []}), - ("thunderstorm", {'code': '26c8', 'aliases': ['thunder_and_rain']}), - ("ticket", {'code': '1f39f', 'aliases': []}), - ("tie", {'code': '1f454', 'aliases': []}), - ("tiger", {'code': '1f405', 'aliases': []}), - ("tiger_cub", {'code': '1f42f', 'aliases': []}), - ("time", {'code': '1f557', 'aliases': ['clock']}), - ("time_ticking", {'code': '23f3', 'aliases': ['hourglass']}), - ("timer", {'code': '23f2', 'aliases': []}), - ("times_up", {'code': '231b', 'aliases': ['hourglass_done']}), - ("tm", {'code': '2122', 'aliases': ['trademark']}), - ("toilet", {'code': '1f6bd', 'aliases': []}), - ("tomato", {'code': '1f345', 'aliases': []}), - ("tongue", {'code': '1f445', 'aliases': []}), - ("toolbox", {'code': '1f9f0', 'aliases': ['chest']}), - ("tooth", {'code': '1f9b7', 'aliases': ['dentist']}), - ("toothbrush", {'code': '1faa5', 'aliases': ['bathroom', 'brush', 'dental', 'hygiene', 'teeth']}), - ("top", {'code': '1f51d', 'aliases': []}), - ("top_hat", {'code': '1f3a9', 'aliases': []}), - ("tornado", {'code': '1f32a', 'aliases': []}), - ("tower", {'code': '1f5fc', 'aliases': ['tokyo_tower']}), - ("trackball", {'code': '1f5b2', 'aliases': []}), - ("tractor", {'code': '1f69c', 'aliases': []}), - ("traffic_light", {'code': '1f6a6', 'aliases': ['vertical_traffic_light']}), - ("train", {'code': '1f682', 'aliases': ['steam_locomotive']}), - ("tram", {'code': '1f68b', 'aliases': ['streetcar']}), - ("transgender_flag", {'code': '1f3f3-fe0f-200d-26a7-fe0f', 'aliases': ['light_blue', 'pink']}), - ("transgender_symbol", {'code': '26a7', 'aliases': []}), - ("tree", {'code': '1f333', 'aliases': ['deciduous_tree']}), - ("triangular_flag", {'code': '1f6a9', 'aliases': []}), - ("trident", {'code': '1f531', 'aliases': []}), - ("triumph", {'code': '1f624', 'aliases': []}), - ("troll", {'code': '1f9cc', 'aliases': ['fairy_tale', 'fantasy', 'monster']}), - ("trolley", {'code': '1f68e', 'aliases': []}), - ("trophy", {'code': '1f3c6', 'aliases': ['winner']}), - ("tropical_drink", {'code': '1f379', 'aliases': []}), - ("tropical_fish", {'code': '1f420', 'aliases': []}), - ("truck", {'code': '1f69b', 'aliases': ['tractor-trailer', 'big_rig', 'semi_truck', 'transport_truck']}), - ("trumpet", {'code': '1f3ba', 'aliases': []}), - ("tulip", {'code': '1f337', 'aliases': ['flower']}), - ("turban", {'code': '1f473', 'aliases': []}), - ("turkey", {'code': '1f983', 'aliases': []}), - ("turtle", {'code': '1f422', 'aliases': ['tortoise']}), - ("tuxedo", {'code': '1f935', 'aliases': []}), - ("tv", {'code': '1f4fa', 'aliases': ['television']}), - ("two", {'code': '0032-20e3', 'aliases': []}), - ("two_hearts", {'code': '1f495', 'aliases': []}), - ("two_men_holding_hands", {'code': '1f46c', 'aliases': ['men_couple']}), - ("two_women_holding_hands", {'code': '1f46d', 'aliases': ['women_couple']}), - ("umbrella", {'code': '2602', 'aliases': []}), - ("umbrella_with_rain", {'code': '2614', 'aliases': []}), - ("umm", {'code': '1f4ac', 'aliases': ['speech_balloon']}), - ("unamused", {'code': '1f612', 'aliases': []}), - ("underage", {'code': '1f51e', 'aliases': ['nc17']}), - ("unicorn", {'code': '1f984', 'aliases': []}), - ("unlocked", {'code': '1f513', 'aliases': []}), - ("unread_mail", {'code': '1f4ec', 'aliases': []}), - ("up", {'code': '2b06', 'aliases': ['north']}), - ("up_down", {'code': '2195', 'aliases': []}), - ("upper_left", {'code': '2196', 'aliases': ['north_west']}), - ("upper_right", {'code': '2197', 'aliases': ['north_east']}), - ("upside_down", {'code': '1f643', 'aliases': ['oops']}), - ("upvote", {'code': '1f53c', 'aliases': ['up_button', 'increase']}), - ("vampire", {'code': '1f9db', 'aliases': []}), - ("vase", {'code': '1f3fa', 'aliases': ['amphora']}), - ("vhs", {'code': '1f4fc', 'aliases': ['videocassette']}), - ("vibration_mode", {'code': '1f4f3', 'aliases': []}), - ("video_camera", {'code': '1f4f9', 'aliases': ['video_recorder']}), - ("video_game", {'code': '1f3ae', 'aliases': []}), - ("violin", {'code': '1f3bb', 'aliases': []}), - ("virgo", {'code': '264d', 'aliases': []}), - ("volcano", {'code': '1f30b', 'aliases': []}), - ("volleyball", {'code': '1f3d0', 'aliases': []}), - ("volume", {'code': '1f39a', 'aliases': ['level_slider']}), - ("vs", {'code': '1f19a', 'aliases': []}), - ("waffle", {'code': '1f9c7', 'aliases': ['indecisive', 'iron']}), - ("wait_one_second", {'code': '261d', 'aliases': ['point_of_information', 'asking_a_question']}), - ("walking", {'code': '1f6b6', 'aliases': ['pedestrian']}), - ("waning_crescent_moon", {'code': '1f318', 'aliases': []}), - ("waning_gibbous_moon", {'code': '1f316', 'aliases': ['gibbous']}), - ("warning", {'code': '26a0', 'aliases': ['caution', 'danger']}), - ("wastebasket", {'code': '1f5d1', 'aliases': ['trash_can']}), - ("watch", {'code': '231a', 'aliases': []}), - ("water_buffalo", {'code': '1f403', 'aliases': []}), - ("water_polo", {'code': '1f93d', 'aliases': []}), - ("watermelon", {'code': '1f349', 'aliases': []}), - ("wave", {'code': '1f44b', 'aliases': ['hello', 'hi']}), - ("wavy_dash", {'code': '3030', 'aliases': []}), - ("waxing_crescent_moon", {'code': '1f312', 'aliases': ['waxing']}), - ("waxing_moon", {'code': '1f314', 'aliases': []}), - ("wc", {'code': '1f6be', 'aliases': ['water_closet']}), - ("weary", {'code': '1f629', 'aliases': ['distraught']}), - ("web", {'code': '1f578', 'aliases': ['spider_web']}), - ("wedding", {'code': '1f492', 'aliases': []}), - ("whale", {'code': '1f433', 'aliases': []}), - ("wheel", {'code': '1f6de', 'aliases': ['tire', 'turn']}), - ("wheel_of_dharma", {'code': '2638', 'aliases': ['buddhism']}), - ("white_and_black_square", {'code': '1f532', 'aliases': []}), - ("white_cane", {'code': '1f9af', 'aliases': []}), - ("white_circle", {'code': '26aa', 'aliases': []}), - ("white_flag", {'code': '1f3f3', 'aliases': ['surrender']}), - ("white_flower", {'code': '1f4ae', 'aliases': []}), - ("white_heart", {'code': '1f90d', 'aliases': []}), - ("white_large_square", {'code': '2b1c', 'aliases': []}), - ("white_medium_small_square", {'code': '25fd', 'aliases': []}), - ("white_medium_square", {'code': '25fb', 'aliases': []}), - ("white_small_square", {'code': '25ab', 'aliases': []}), - ("wilted_flower", {'code': '1f940', 'aliases': ['crushed']}), - ("wind_chime", {'code': '1f390', 'aliases': []}), - ("window", {'code': '1fa9f', 'aliases': ['frame', 'fresh_air', 'opening', 'transparent', 'view']}), - ("windy", {'code': '1f32c', 'aliases': ['mother_nature']}), - ("wine", {'code': '1f377', 'aliases': []}), - ("wink", {'code': '1f609', 'aliases': []}), - ("wish_tree", {'code': '1f38b', 'aliases': ['tanabata_tree']}), - ("wolf", {'code': '1f43a', 'aliases': []}), - ("woman", {'code': '1f469', 'aliases': []}), - ("woman_artist", {'code': '1f469-200d-1f3a8', 'aliases': []}), - ("woman_astronaut", {'code': '1f469-200d-1f680', 'aliases': []}), - ("woman_bald", {'code': '1f469-200d-1f9b2', 'aliases': []}), - ("woman_beard", {'code': '1f9d4-200d-2640', 'aliases': []}), - ("woman_biking", {'code': '1f6b4-200d-2640', 'aliases': []}), - ("woman_blond_hair", {'code': '1f471-200d-2640', 'aliases': ['blond_haired_woman', 'blonde']}), - ("woman_bouncing_ball", {'code': '26f9-fe0f-200d-2640-fe0f', 'aliases': []}), - ("woman_bowing", {'code': '1f647-200d-2640', 'aliases': []}), - ("woman_cartwheeling", {'code': '1f938-200d-2640', 'aliases': []}), - ("woman_climbing", {'code': '1f9d7-200d-2640', 'aliases': []}), - ("woman_construction_worker", {'code': '1f477-200d-2640', 'aliases': []}), - ("woman_cook", {'code': '1f469-200d-1f373', 'aliases': []}), - ("woman_curly_hair", {'code': '1f469-200d-1f9b1', 'aliases': []}), - ("woman_detective", {'code': '1f575-fe0f-200d-2640-fe0f', 'aliases': []}), - ("woman_elf", {'code': '1f9dd-200d-2640', 'aliases': []}), - ("woman_facepalming", {'code': '1f926-200d-2640', 'aliases': []}), - ("woman_factory_worker", {'code': '1f469-200d-1f3ed', 'aliases': []}), - ("woman_fairy", {'code': '1f9da-200d-2640', 'aliases': []}), - ("woman_farmer", {'code': '1f469-200d-1f33e', 'aliases': []}), - ("woman_feeding_baby", {'code': '1f469-200d-1f37c', 'aliases': []}), - ("woman_firefighter", {'code': '1f469-200d-1f692', 'aliases': []}), - ("woman_frowning", {'code': '1f64d-200d-2640', 'aliases': []}), - ("woman_genie", {'code': '1f9de-200d-2640', 'aliases': []}), - ("woman_gesturing_no", {'code': '1f645-200d-2640', 'aliases': []}), - ("woman_gesturing_ok", {'code': '1f646-200d-2640', 'aliases': []}), - ("woman_getting_haircut", {'code': '1f487-200d-2640', 'aliases': []}), - ("woman_getting_massage", {'code': '1f486-200d-2640', 'aliases': []}), - ("woman_golfing", {'code': '1f3cc-fe0f-200d-2640-fe0f', 'aliases': []}), - ("woman_guard", {'code': '1f482-200d-2640', 'aliases': []}), - ("woman_health_worker", {'code': '1f469-200d-2695', 'aliases': []}), - ("woman_in_lotus_position", {'code': '1f9d8-200d-2640', 'aliases': []}), - ("woman_in_manual_wheelchair", {'code': '1f469-200d-1f9bd', 'aliases': []}), - ("woman_in_motorized_wheelchair", {'code': '1f469-200d-1f9bc', 'aliases': []}), - ("woman_in_steamy_room", {'code': '1f9d6-200d-2640', 'aliases': []}), - ("woman_in_tuxedo", {'code': '1f935-200d-2640', 'aliases': []}), - ("woman_judge", {'code': '1f469-200d-2696', 'aliases': []}), - ("woman_juggling", {'code': '1f939-200d-2640', 'aliases': []}), - ("woman_kneeling", {'code': '1f9ce-200d-2640', 'aliases': []}), - ("woman_lifting_weights", {'code': '1f3cb-fe0f-200d-2640-fe0f', 'aliases': []}), - ("woman_mage", {'code': '1f9d9-200d-2640', 'aliases': []}), - ("woman_mechanic", {'code': '1f469-200d-1f527', 'aliases': []}), - ("woman_mountain_biking", {'code': '1f6b5-200d-2640', 'aliases': []}), - ("woman_office_worker", {'code': '1f469-200d-1f4bc', 'aliases': []}), - ("woman_pilot", {'code': '1f469-200d-2708', 'aliases': []}), - ("woman_playing_handball", {'code': '1f93e-200d-2640', 'aliases': []}), - ("woman_playing_water_polo", {'code': '1f93d-200d-2640', 'aliases': []}), - ("woman_police_officer", {'code': '1f46e-200d-2640', 'aliases': []}), - ("woman_pouting", {'code': '1f64e-200d-2640', 'aliases': []}), - ("woman_raising_hand", {'code': '1f64b-200d-2640', 'aliases': []}), - ("woman_red_hair", {'code': '1f469-200d-1f9b0', 'aliases': []}), - ("woman_rowing_boat", {'code': '1f6a3-200d-2640', 'aliases': []}), - ("woman_running", {'code': '1f3c3-200d-2640', 'aliases': []}), - ("woman_scientist", {'code': '1f469-200d-1f52c', 'aliases': []}), - ("woman_shrugging", {'code': '1f937-200d-2640', 'aliases': []}), - ("woman_singer", {'code': '1f469-200d-1f3a4', 'aliases': []}), - ("woman_standing", {'code': '1f9cd-200d-2640', 'aliases': []}), - ("woman_student", {'code': '1f469-200d-1f393', 'aliases': []}), - ("woman_superhero", {'code': '1f9b8-200d-2640', 'aliases': []}), - ("woman_supervillain", {'code': '1f9b9-200d-2640', 'aliases': []}), - ("woman_surfing", {'code': '1f3c4-200d-2640', 'aliases': []}), - ("woman_swimming", {'code': '1f3ca-200d-2640', 'aliases': []}), - ("woman_teacher", {'code': '1f469-200d-1f3eb', 'aliases': []}), - ("woman_technologist", {'code': '1f469-200d-1f4bb', 'aliases': []}), - ("woman_tipping_hand", {'code': '1f481-200d-2640', 'aliases': []}), - ("woman_vampire", {'code': '1f9db-200d-2640', 'aliases': []}), - ("woman_walking", {'code': '1f6b6-200d-2640', 'aliases': []}), - ("woman_wearing_turban", {'code': '1f473-200d-2640', 'aliases': []}), - ("woman_white_hair", {'code': '1f469-200d-1f9b3', 'aliases': []}), - ("woman_with_headscarf", {'code': '1f9d5', 'aliases': ['headscarf', 'hijab', 'mantilla', 'tichel']}), - ("woman_with_veil", {'code': '1f470-200d-2640', 'aliases': []}), - ("woman_with_white_cane", {'code': '1f469-200d-1f9af', 'aliases': []}), - ("woman_zombie", {'code': '1f9df-200d-2640', 'aliases': []}), - ("women_with_bunny_ears", {'code': '1f46f-200d-2640', 'aliases': []}), - ("women_wrestling", {'code': '1f93c-200d-2640', 'aliases': []}), - ("womens", {'code': '1f6ba', 'aliases': []}), - ("wood", {'code': '1fab5', 'aliases': ['log', 'timber']}), - ("woozy_face", {'code': '1f974', 'aliases': ['intoxicated', 'tipsy', 'uneven_eyes', 'wavy_mouth']}), - ("work_in_progress", {'code': '1f6a7', 'aliases': ['construction_zone']}), - ("working_on_it", {'code': '1f6e0', 'aliases': ['hammer_and_wrench', 'tools']}), - ("worm", {'code': '1fab1', 'aliases': ['annelid', 'earthworm', 'parasite']}), - ("worried", {'code': '1f61f', 'aliases': []}), - ("wrestling", {'code': '1f93c', 'aliases': []}), - ("writing", {'code': '270d', 'aliases': []}), - ("www", {'code': '1f310', 'aliases': ['globe']}), - ("x", {'code': '274e', 'aliases': []}), - ("x_ray", {'code': '1fa7b', 'aliases': ['bones', 'medical']}), - ("yam", {'code': '1f360', 'aliases': ['sweet_potato']}), - ("yarn", {'code': '1f9f6', 'aliases': ['crochet', 'knit']}), - ("yawning_face", {'code': '1f971', 'aliases': ['bored', 'yawn']}), - ("yellow_circle", {'code': '1f7e1', 'aliases': ['yellow']}), - ("yellow_heart", {'code': '1f49b', 'aliases': ['heart_of_gold']}), - ("yellow_large_square", {'code': '1f7e8', 'aliases': []}), - ("yen_banknotes", {'code': '1f4b4', 'aliases': []}), - ("yin_yang", {'code': '262f', 'aliases': []}), - ("yo_yo", {'code': '1fa80', 'aliases': ['fluctuate']}), - ("yum", {'code': '1f60b', 'aliases': []}), - ("zany_face", {'code': '1f92a', 'aliases': ['goofy', 'small']}), - ("zebra", {'code': '1f993', 'aliases': ['stripe']}), - ("zero", {'code': '0030-20e3', 'aliases': []}), - ("zombie", {'code': '1f9df', 'aliases': []}), - ("zzz", {'code': '1f4a4', 'aliases': []}), - ] -) +EMOJI_DATA = { + "+1": {'code': '1f44d', 'aliases': ['thumbs_up', 'like']}, + "-1": {'code': '1f44e', 'aliases': ['thumbs_down']}, + "100": {'code': '1f4af', 'aliases': ['hundred']}, + "1234": {'code': '1f522', 'aliases': ['numbers']}, + "a": {'code': '1f170', 'aliases': []}, + "ab": {'code': '1f18e', 'aliases': []}, + "abacus": {'code': '1f9ee', 'aliases': ['calculation']}, + "abc": {'code': '1f524', 'aliases': []}, + "abcd": {'code': '1f521', 'aliases': ['alphabet']}, + "accessible": {'code': '267f', 'aliases': ['wheelchair', 'disabled']}, + "accordion": {'code': '1fa97', 'aliases': ['concertina', 'squeeze_box']}, + "action": {'code': '1f3ac', 'aliases': []}, + "adhesive_bandage": {'code': '1fa79', 'aliases': ['bandage']}, + "aerial_tramway": {'code': '1f6a1', 'aliases': ['ski_lift']}, + "airplane": {'code': '2708', 'aliases': []}, + "alarm_clock": {'code': '23f0', 'aliases': []}, + "alchemy": {'code': '2697', 'aliases': ['alembic']}, + "alien": {'code': '1f47d', 'aliases': ['ufo']}, + "ambulance": {'code': '1f691', 'aliases': []}, + "american_football": {'code': '1f3c8', 'aliases': []}, + "anatomical_heart": {'code': '1fac0', 'aliases': ['anatomical', 'cardiology', 'pulse']}, + "anchor": {'code': '2693', 'aliases': []}, + "angel": {'code': '1f47c', 'aliases': []}, + "anger": {'code': '1f4a2', 'aliases': ['bam', 'pow']}, + "anger_bubble": {'code': '1f5ef', 'aliases': []}, + "angry": {'code': '1f620', 'aliases': []}, + "angry_cat": {'code': '1f63e', 'aliases': ['pouting_cat']}, + "anguish": {'code': '1f62b', 'aliases': []}, + "anguished": {'code': '1f627', 'aliases': ['pained']}, + "ant": {'code': '1f41c', 'aliases': []}, + "apple": {'code': '1f34e', 'aliases': []}, + "aquarius": {'code': '2652', 'aliases': []}, + "arabian_camel": {'code': '1f42a', 'aliases': []}, + "archive": {'code': '1f5c3', 'aliases': []}, + "aries": {'code': '2648', 'aliases': []}, + "art": {'code': '1f3a8', 'aliases': ['palette', 'painting']}, + "artist": {'code': '1f9d1-200d-1f3a8', 'aliases': []}, + "asterisk": {'code': '002a-20e3', 'aliases': []}, + "astonished": {'code': '1f632', 'aliases': []}, + "astronaut": {'code': '1f9d1-200d-1f680', 'aliases': []}, + "at_work": {'code': '2692', 'aliases': ['hammer_and_pick']}, + "athletic_shoe": {'code': '1f45f', 'aliases': ['sneaker', 'running_shoe']}, + "atm": {'code': '1f3e7', 'aliases': []}, + "atom": {'code': '269b', 'aliases': ['physics']}, + "auto_rickshaw": {'code': '1f6fa', 'aliases': ['tuk_tuk']}, + "avocado": {'code': '1f951', 'aliases': []}, + "axe": {'code': '1fa93', 'aliases': ['hatchet', 'split']}, + "b": {'code': '1f171', 'aliases': []}, + "baby": {'code': '1f476', 'aliases': []}, + "baby_bottle": {'code': '1f37c', 'aliases': []}, + "baby_change_station": {'code': '1f6bc', 'aliases': ['nursery']}, + "back": {'code': '1f519', 'aliases': []}, + "backpack": {'code': '1f392', 'aliases': ['satchel']}, + "bacon": {'code': '1f953', 'aliases': []}, + "badger": {'code': '1f9a1', 'aliases': ['honey_badger', 'pester']}, + "badminton": {'code': '1f3f8', 'aliases': []}, + "bagel": {'code': '1f96f', 'aliases': ['schmear']}, + "baggage_claim": {'code': '1f6c4', 'aliases': []}, + "baguette": {'code': '1f956', 'aliases': []}, + "ball": {'code': '26f9', 'aliases': ['sports']}, + "ballet_shoes": {'code': '1fa70', 'aliases': ['ballet']}, + "balloon": {'code': '1f388', 'aliases': ['celebration']}, + "ballot_box": {'code': '1f5f3', 'aliases': []}, + "bamboo": {'code': '1f38d', 'aliases': []}, + "banana": {'code': '1f34c', 'aliases': []}, + "bangbang": {'code': '203c', 'aliases': ['double_exclamation']}, + "banjo": {'code': '1fa95', 'aliases': ['stringed']}, + "bank": {'code': '1f3e6', 'aliases': []}, + "bar_chart": {'code': '1f4ca', 'aliases': []}, + "barber": {'code': '1f488', 'aliases': ['striped_pole']}, + "baseball": {'code': '26be', 'aliases': []}, + "basket": {'code': '1f9fa', 'aliases': ['farming', 'laundry', 'picnic']}, + "basketball": {'code': '1f3c0', 'aliases': []}, + "bat": {'code': '1f987', 'aliases': []}, + "bath": {'code': '1f6c0', 'aliases': []}, + "bathtub": {'code': '1f6c1', 'aliases': []}, + "battery": {'code': '1f50b', 'aliases': ['full_battery']}, + "beach": {'code': '1f3d6', 'aliases': []}, + "beach_umbrella": {'code': '26f1', 'aliases': []}, + "beans": {'code': '1fad8', 'aliases': ['kidney', 'legume']}, + "bear": {'code': '1f43b', 'aliases': []}, + "beaver": {'code': '1f9ab', 'aliases': ['dam']}, + "bed": {'code': '1f6cf', 'aliases': ['bedroom']}, + "bee": {'code': '1f41d', 'aliases': ['buzz', 'honeybee']}, + "beer": {'code': '1f37a', 'aliases': []}, + "beers": {'code': '1f37b', 'aliases': []}, + "beetle": {'code': '1fab2', 'aliases': []}, + "beginner": {'code': '1f530', 'aliases': []}, + "bell_pepper": {'code': '1fad1', 'aliases': ['capsicum', 'pepper', 'vegetable']}, + "bellhop_bell": {'code': '1f6ce', 'aliases': ['reception', 'services', 'ding']}, + "bento": {'code': '1f371', 'aliases': []}, + "beverage_box": {'code': '1f9c3', 'aliases': ['beverage', 'box', 'straw']}, + "big_smile": {'code': '1f604', 'aliases': []}, + "bike": {'code': '1f6b2', 'aliases': ['bicycle']}, + "bikini": {'code': '1f459', 'aliases': []}, + "billed_cap": {'code': '1f9e2', 'aliases': ['baseball_cap']}, + "billiards": {'code': '1f3b1', 'aliases': ['pool', '8_ball']}, + "biohazard": {'code': '2623', 'aliases': []}, + "bird": {'code': '1f426', 'aliases': []}, + "birthday": {'code': '1f382', 'aliases': []}, + "bison": {'code': '1f9ac', 'aliases': ['buffalo', 'herd', 'wisent']}, + "biting_lip": {'code': '1fae6', 'aliases': ['flirting', 'uncomfortable']}, + "black_and_white_square": {'code': '1f533', 'aliases': []}, + "black_belt": {'code': '1f94b', 'aliases': ['keikogi', 'dogi', 'martial_arts']}, + "black_cat": {'code': '1f408-200d-2b1b', 'aliases': ['black', 'unlucky']}, + "black_circle": {'code': '26ab', 'aliases': []}, + "black_flag": {'code': '1f3f4', 'aliases': []}, + "black_heart": {'code': '1f5a4', 'aliases': []}, + "black_large_square": {'code': '2b1b', 'aliases': []}, + "black_medium_small_square": {'code': '25fe', 'aliases': []}, + "black_medium_square": {'code': '25fc', 'aliases': []}, + "black_nib": {'code': '2712', 'aliases': ['nib']}, + "black_small_square": {'code': '25aa', 'aliases': []}, + "blossom": {'code': '1f33c', 'aliases': []}, + "blowfish": {'code': '1f421', 'aliases': []}, + "blue_book": {'code': '1f4d8', 'aliases': []}, + "blue_circle": {'code': '1f535', 'aliases': []}, + "blue_heart": {'code': '1f499', 'aliases': []}, + "blue_square": {'code': '1f7e6', 'aliases': []}, + "blueberries": {'code': '1fad0', 'aliases': ['berry', 'bilberry', 'blueberry']}, + "blush": {'code': '1f60a', 'aliases': []}, + "boar": {'code': '1f417', 'aliases': []}, + "boat": {'code': '26f5', 'aliases': ['sailboat']}, + "bomb": {'code': '1f4a3', 'aliases': []}, + "bone": {'code': '1f9b4', 'aliases': []}, + "book": {'code': '1f4d6', 'aliases': ['open_book']}, + "bookmark": {'code': '1f516', 'aliases': []}, + "books": {'code': '1f4da', 'aliases': []}, + "boom": {'code': '1f4a5', 'aliases': ['explosion', 'crash', 'collision']}, + "boomerang": {'code': '1fa83', 'aliases': ['rebound', 'repercussion']}, + "boot": {'code': '1f462', 'aliases': []}, + "bouquet": {'code': '1f490', 'aliases': []}, + "bow": {'code': '1f647', 'aliases': []}, + "bow_and_arrow": {'code': '1f3f9', 'aliases': ['archery']}, + "bowl_with_spoon": {'code': '1f963', 'aliases': ['cereal', 'congee']}, + "boxing_glove": {'code': '1f94a', 'aliases': []}, + "boy": {'code': '1f466', 'aliases': []}, + "brain": {'code': '1f9e0', 'aliases': ['intelligent']}, + "bread": {'code': '1f35e', 'aliases': []}, + "breast_feeding": {'code': '1f931', 'aliases': ['breast']}, + "brick": {'code': '1f9f1', 'aliases': ['bricks', 'clay', 'mortar', 'wall']}, + "bride": {'code': '1f470', 'aliases': []}, + "bridge": {'code': '1f309', 'aliases': []}, + "briefcase": {'code': '1f4bc', 'aliases': []}, + "briefs": {'code': '1fa72', 'aliases': ['one_piece', 'swimsuit']}, + "brightness": {'code': '1f506', 'aliases': ['high_brightness']}, + "broccoli": {'code': '1f966', 'aliases': ['wild_cabbage']}, + "broken_heart": {'code': '1f494', 'aliases': ['heartache']}, + "broom": {'code': '1f9f9', 'aliases': ['sweeping']}, + "brown_circle": {'code': '1f7e4', 'aliases': []}, + "brown_heart": {'code': '1f90e', 'aliases': []}, + "brown_square": {'code': '1f7eb', 'aliases': []}, + "bubble_tea": {'code': '1f9cb', 'aliases': []}, + "bubbles": {'code': '1fae7', 'aliases': ['burp', 'underwater']}, + "bucket": {'code': '1faa3', 'aliases': ['cask', 'pail', 'vat']}, + "bug": {'code': '1f41b', 'aliases': ['caterpillar']}, + "bullet_train": {'code': '1f685', 'aliases': []}, + "bunny": {'code': '1f430', 'aliases': []}, + "burrito": {'code': '1f32f', 'aliases': []}, + "bus": {'code': '1f68c', 'aliases': ['school_bus']}, + "bus_stop": {'code': '1f68f', 'aliases': []}, + "butter": {'code': '1f9c8', 'aliases': ['dairy']}, + "butterfly": {'code': '1f98b', 'aliases': []}, + "cactus": {'code': '1f335', 'aliases': []}, + "cake": {'code': '1f370', 'aliases': []}, + "calendar": {'code': '1f4c5', 'aliases': []}, + "calf": {'code': '1f42e', 'aliases': []}, + "call_me": {'code': '1f919', 'aliases': []}, + "calling": {'code': '1f4f2', 'aliases': []}, + "camel": {'code': '1f42b', 'aliases': []}, + "camera": {'code': '1f4f7', 'aliases': []}, + "campsite": {'code': '1f3d5', 'aliases': []}, + "cancer": {'code': '264b', 'aliases': []}, + "candle": {'code': '1f56f', 'aliases': []}, + "candy": {'code': '1f36c', 'aliases': []}, + "canned_food": {'code': '1f96b', 'aliases': ['can']}, + "canoe": {'code': '1f6f6', 'aliases': []}, + "capital_abcd": {'code': '1f520', 'aliases': ['capital_letters']}, + "capricorn": {'code': '2651', 'aliases': []}, + "car": {'code': '1f697', 'aliases': []}, + "carousel": {'code': '1f3a0', 'aliases': ['merry_go_round']}, + "carp_streamer": {'code': '1f38f', 'aliases': ['flags']}, + "carpenter_square": {'code': '1f4d0', 'aliases': ['triangular_ruler']}, + "carpentry_saw": {'code': '1fa9a', 'aliases': ['carpenter', 'saw']}, + "carrot": {'code': '1f955', 'aliases': []}, + "cartwheel": {'code': '1f938', 'aliases': ['acrobatics', 'gymnastics', 'tumbling']}, + "castle": {'code': '1f3f0', 'aliases': []}, + "cat": {'code': '1f408', 'aliases': ['meow']}, + "cd": {'code': '1f4bf', 'aliases': []}, + "cell_reception": {'code': '1f4f6', 'aliases': ['signal_strength', 'signal_bars']}, + "chains": {'code': '26d3', 'aliases': []}, + "chair": {'code': '1fa91', 'aliases': ['sit']}, + "champagne": {'code': '1f37e', 'aliases': []}, + "chart": {'code': '1f4c8', 'aliases': ['upwards_trend', 'growing', 'increasing']}, + "check": {'code': '2705', 'aliases': ['all_good', 'approved']}, + "check_mark": {'code': '2714', 'aliases': []}, + "checkbox": {'code': '2611', 'aliases': []}, + "checkered_flag": {'code': '1f3c1', 'aliases': ['race', 'go', 'start']}, + "cheese": {'code': '1f9c0', 'aliases': []}, + "cherries": {'code': '1f352', 'aliases': []}, + "cherry_blossom": {'code': '1f338', 'aliases': []}, + "chess_pawn": {'code': '265f', 'aliases': ['chess', 'dupe', 'expendable']}, + "chestnut": {'code': '1f330', 'aliases': []}, + "chick": {'code': '1f424', 'aliases': ['baby_chick']}, + "chicken": {'code': '1f414', 'aliases': ['cluck']}, + "child": {'code': '1f9d2', 'aliases': ['young']}, + "children_crossing": {'code': '1f6b8', 'aliases': ['school_crossing', 'drive_with_care']}, + "chipmunk": {'code': '1f43f', 'aliases': []}, + "chocolate": {'code': '1f36b', 'aliases': []}, + "chopsticks": {'code': '1f962', 'aliases': ['hashi']}, + "church": {'code': '26ea', 'aliases': []}, + "cinema": {'code': '1f3a6', 'aliases': ['movie_theater']}, + "circle": {'code': '2b55', 'aliases': []}, + "circus": {'code': '1f3aa', 'aliases': []}, + "city": {'code': '1f3d9', 'aliases': ['skyline']}, + "city_sunrise": {'code': '1f307', 'aliases': []}, + "cl": {'code': '1f191', 'aliases': []}, + "clap": {'code': '1f44f', 'aliases': ['applause']}, + "classical_building": {'code': '1f3db', 'aliases': []}, + "clink": {'code': '1f942', 'aliases': ['toast']}, + "clipboard": {'code': '1f4cb', 'aliases': []}, + "clockwise": {'code': '1f503', 'aliases': []}, + "closed_mailbox": {'code': '1f4ea', 'aliases': []}, + "closed_umbrella": {'code': '1f302', 'aliases': []}, + "clothing": {'code': '1f45a', 'aliases': []}, + "cloud": {'code': '2601', 'aliases': ['overcast']}, + "cloudy": {'code': '1f325', 'aliases': []}, + "clown": {'code': '1f921', 'aliases': []}, + "clubs": {'code': '2663', 'aliases': []}, + "coat": {'code': '1f9e5', 'aliases': ['jacket']}, + "cockroach": {'code': '1fab3', 'aliases': ['roach']}, + "cocktail": {'code': '1f378', 'aliases': []}, + "coconut": {'code': '1f965', 'aliases': ['piña_colada', 'pina_colada']}, + "coffee": {'code': '2615', 'aliases': []}, + "coffin": {'code': '26b0', 'aliases': ['burial', 'grave']}, + "coin": {'code': '1fa99', 'aliases': ['metal']}, + "cold_face": {'code': '1f976', 'aliases': ['blue_faced', 'freezing', 'frostbite', 'icicles']}, + "cold_sweat": {'code': '1f630', 'aliases': []}, + "comet": {'code': '2604', 'aliases': ['meteor']}, + "compass": {'code': '1f9ed', 'aliases': ['navigation', 'orienteering']}, + "compression": {'code': '1f5dc', 'aliases': ['vise']}, + "computer": {'code': '1f4bb', 'aliases': ['laptop']}, + "computer_mouse": {'code': '1f5b1', 'aliases': []}, + "confetti": {'code': '1f38a', 'aliases': ['party_ball']}, + "confounded": {'code': '1f616', 'aliases': ['agony']}, + "construction": {'code': '1f3d7', 'aliases': []}, + "construction_worker": {'code': '1f477', 'aliases': []}, + "control_knobs": {'code': '1f39b', 'aliases': []}, + "convenience_store": {'code': '1f3ea', 'aliases': []}, + "cook": {'code': '1f9d1-200d-1f373', 'aliases': []}, + "cookie": {'code': '1f36a', 'aliases': []}, + "cooking": {'code': '1f373', 'aliases': []}, + "cool": {'code': '1f192', 'aliases': []}, + "copyright": {'code': '00a9', 'aliases': ['c']}, + "coral": {'code': '1fab8', 'aliases': ['reef']}, + "corn": {'code': '1f33d', 'aliases': ['maize']}, + "counterclockwise": {'code': '1f504', 'aliases': ['return']}, + "couple_with_heart": {'code': '1f491', 'aliases': []}, + "couple_with_heart_man_man": {'code': '1f468-200d-2764-200d-1f468', 'aliases': []}, + "couple_with_heart_woman_man": {'code': '1f469-200d-2764-200d-1f468', 'aliases': []}, + "couple_with_heart_woman_woman": {'code': '1f469-200d-2764-200d-1f469', 'aliases': []}, + "cow": {'code': '1f404', 'aliases': []}, + "cowboy": {'code': '1f920', 'aliases': []}, + "crab": {'code': '1f980', 'aliases': []}, + "crayon": {'code': '1f58d', 'aliases': []}, + "credit_card": {'code': '1f4b3', 'aliases': ['debit_card']}, + "cricket": {'code': '1f997', 'aliases': ['grasshopper']}, + "cricket_game": {'code': '1f3cf', 'aliases': []}, + "crocodile": {'code': '1f40a', 'aliases': []}, + "croissant": {'code': '1f950', 'aliases': []}, + "cross": {'code': '271d', 'aliases': ['christianity']}, + "cross_mark": {'code': '274c', 'aliases': ['incorrect', 'wrong']}, + "crossed_flags": {'code': '1f38c', 'aliases': ['solidarity']}, + "crown": {'code': '1f451', 'aliases': ['queen', 'king']}, + "crutch": {'code': '1fa7c', 'aliases': ['cane', 'disability', 'mobility_aid']}, + "cry": {'code': '1f622', 'aliases': []}, + "crying_cat": {'code': '1f63f', 'aliases': []}, + "crystal_ball": {'code': '1f52e', 'aliases': ['oracle', 'future', 'fortune_telling']}, + "cucumber": {'code': '1f952', 'aliases': []}, + "cup_with_straw": {'code': '1f964', 'aliases': ['soda']}, + "cupcake": {'code': '1f9c1', 'aliases': []}, + "cupid": {'code': '1f498', 'aliases': ['smitten', 'heart_arrow']}, + "curling_stone": {'code': '1f94c', 'aliases': []}, + "curry": {'code': '1f35b', 'aliases': []}, + "custard": {'code': '1f36e', 'aliases': ['flan']}, + "customs": {'code': '1f6c3', 'aliases': []}, + "cut_of_meat": {'code': '1f969', 'aliases': ['lambchop', 'porkchop', 'steak']}, + "cute": {'code': '1f4a0', 'aliases': ['kawaii', 'diamond_with_a_dot']}, + "cyclist": {'code': '1f6b4', 'aliases': []}, + "cyclone": {'code': '1f300', 'aliases': ['hurricane', 'typhoon']}, + "dagger": {'code': '1f5e1', 'aliases': ['rated_for_violence']}, + "dancer": {'code': '1f483', 'aliases': []}, + "dancers": {'code': '1f46f', 'aliases': []}, + "dancing": {'code': '1f57a', 'aliases': ['disco']}, + "dango": {'code': '1f361', 'aliases': []}, + "dark_sunglasses": {'code': '1f576', 'aliases': []}, + "dash": {'code': '1f4a8', 'aliases': []}, + "date": {'code': '1f4c6', 'aliases': []}, + "deaf_man": {'code': '1f9cf-200d-2642', 'aliases': []}, + "deaf_person": {'code': '1f9cf', 'aliases': ['hear']}, + "deaf_woman": {'code': '1f9cf-200d-2640', 'aliases': []}, + "decorative_notebook": {'code': '1f4d4', 'aliases': []}, + "deer": {'code': '1f98c', 'aliases': []}, + "department_store": {'code': '1f3ec', 'aliases': []}, + "derelict_house": {'code': '1f3da', 'aliases': ['condemned']}, + "desert": {'code': '1f3dc', 'aliases': []}, + "desktop_computer": {'code': '1f5a5', 'aliases': []}, + "detective": {'code': '1f575', 'aliases': ['spy', 'sleuth', 'agent', 'sneaky']}, + "devil": {'code': '1f47f', 'aliases': ['imp', 'angry_devil']}, + "diamonds": {'code': '2666', 'aliases': []}, + "dice": {'code': '1f3b2', 'aliases': ['die']}, + "direct_hit": {'code': '1f3af', 'aliases': ['darts', 'bulls_eye']}, + "disappointed": {'code': '1f61e', 'aliases': []}, + "disguised_face": {'code': '1f978', 'aliases': ['disguise', 'incognito']}, + "diving_mask": {'code': '1f93f', 'aliases': ['scuba', 'snorkeling']}, + "division": {'code': '2797', 'aliases': ['divide']}, + "diya_lamp": {'code': '1fa94', 'aliases': ['diya', 'lamp', 'oil']}, + "dizzy": {'code': '1f635', 'aliases': []}, + "dna": {'code': '1f9ec', 'aliases': ['evolution', 'gene', 'genetics', 'life']}, + "do_not_litter": {'code': '1f6af', 'aliases': []}, + "document": {'code': '1f4c4', 'aliases': ['paper', 'file', 'page']}, + "dodo": {'code': '1f9a4', 'aliases': ['mauritius']}, + "dog": {'code': '1f415', 'aliases': ['woof']}, + "dollar_bills": {'code': '1f4b5', 'aliases': []}, + "dollars": {'code': '1f4b2', 'aliases': []}, + "dolls": {'code': '1f38e', 'aliases': []}, + "dolphin": {'code': '1f42c', 'aliases': ['flipper']}, + "doner_kebab": {'code': '1f959', 'aliases': ['shawarma', 'souvlaki', 'stuffed_flatbread']}, + "donut": {'code': '1f369', 'aliases': ['doughnut']}, + "door": {'code': '1f6aa', 'aliases': []}, + "dormouse": {'code': '1f42d', 'aliases': []}, + "dotted_line_face": {'code': '1fae5', 'aliases': ['depressed', 'hide', 'introvert', 'invisible']}, + "dotted_six_pointed_star": {'code': '1f52f', 'aliases': ['fortune']}, + "double_down": {'code': '23ec', 'aliases': ['fast_down']}, + "double_loop": {'code': '27bf', 'aliases': ['voicemail']}, + "double_up": {'code': '23eb', 'aliases': ['fast_up']}, + "dove": {'code': '1f54a', 'aliases': ['dove_of_peace']}, + "down": {'code': '2b07', 'aliases': ['south']}, + "downvote": {'code': '1f53d', 'aliases': ['down_button', 'decrease']}, + "downwards_trend": {'code': '1f4c9', 'aliases': ['shrinking', 'decreasing']}, + "dragon": {'code': '1f409', 'aliases': []}, + "dragon_face": {'code': '1f432', 'aliases': []}, + "dress": {'code': '1f457', 'aliases': []}, + "drooling": {'code': '1f924', 'aliases': []}, + "drop": {'code': '1f4a7', 'aliases': ['water_drop']}, + "drop_of_blood": {'code': '1fa78', 'aliases': ['bleed', 'blood_donation', 'injury', 'menstruation']}, + "drum": {'code': '1f941', 'aliases': []}, + "drumstick": {'code': '1f357', 'aliases': ['poultry']}, + "duck": {'code': '1f986', 'aliases': []}, + "duel": {'code': '2694', 'aliases': ['swords']}, + "dumpling": {'code': '1f95f', 'aliases': ['empanada', 'gyōza', 'jiaozi', 'pierogi', 'potsticker', 'gyoza']}, + "dvd": {'code': '1f4c0', 'aliases': []}, + "e-mail": {'code': '1f4e7', 'aliases': []}, + "eagle": {'code': '1f985', 'aliases': []}, + "ear": {'code': '1f442', 'aliases': []}, + "ear_with_hearing_aid": {'code': '1f9bb', 'aliases': ['hard_of_hearing']}, + "earth_africa": {'code': '1f30d', 'aliases': []}, + "earth_americas": {'code': '1f30e', 'aliases': []}, + "earth_asia": {'code': '1f30f', 'aliases': []}, + "egg": {'code': '1f95a', 'aliases': []}, + "eggplant": {'code': '1f346', 'aliases': []}, + "eight": {'code': '0038-20e3', 'aliases': []}, + "eight_pointed_star": {'code': '2734', 'aliases': []}, + "eight_spoked_asterisk": {'code': '2733', 'aliases': []}, + "eject_button": {'code': '23cf', 'aliases': ['eject']}, + "electric_plug": {'code': '1f50c', 'aliases': []}, + "elephant": {'code': '1f418', 'aliases': []}, + "elevator": {'code': '1f6d7', 'aliases': ['hoist']}, + "elf": {'code': '1f9dd', 'aliases': []}, + "email": {'code': '2709', 'aliases': ['envelope', 'mail']}, + "empty_nest": {'code': '1fab9', 'aliases': []}, + "end": {'code': '1f51a', 'aliases': []}, + "euro_banknotes": {'code': '1f4b6', 'aliases': []}, + "evergreen_tree": {'code': '1f332', 'aliases': []}, + "exchange": {'code': '1f4b1', 'aliases': []}, + "exclamation": {'code': '2757', 'aliases': []}, + "exhausted": {'code': '1f625', 'aliases': ['disappointed_relieved', 'stressed']}, + "exploding_head": {'code': '1f92f', 'aliases': ['mind_blown', 'shocked']}, + "expressionless": {'code': '1f611', 'aliases': []}, + "eye": {'code': '1f441', 'aliases': []}, + "eye_in_speech_bubble": {'code': '1f441-fe0f-200d-1f5e8-fe0f', 'aliases': ['speech', 'witness']}, + "eyes": {'code': '1f440', 'aliases': ['looking']}, + "face_exhaling": {'code': '1f62e-200d-1f4a8', 'aliases': ['exhale', 'gasp', 'groan', 'relief', 'whisper', 'whistle']}, + "face_holding_back_tears": {'code': '1f979', 'aliases': ['resist']}, + "face_in_clouds": {'code': '1f636-200d-1f32b', 'aliases': ['absentminded', 'face_in_the_fog', 'head_in_clouds']}, + "face_palm": {'code': '1f926', 'aliases': []}, + "face_vomiting": {'code': '1f92e', 'aliases': ['puke', 'vomit']}, + "face_with_diagonal_mouth": {'code': '1fae4', 'aliases': ['meh', 'skeptical', 'unsure']}, + "face_with_hand_over_mouth": {'code': '1f92d', 'aliases': ['whoops']}, + "face_with_monocle": {'code': '1f9d0', 'aliases': ['monocle', 'stuffy']}, + "face_with_open_eyes_and_hand_over_mouth": {'code': '1fae2', 'aliases': ['amazement', 'awe', 'embarrass']}, + "face_with_peeking_eye": {'code': '1fae3', 'aliases': ['captivated', 'peep', 'stare']}, + "face_with_raised_eyebrow": {'code': '1f928', 'aliases': ['distrust', 'skeptic']}, + "face_with_spiral_eyes": {'code': '1f635-200d-1f4ab', 'aliases': ['hypnotized', 'trouble', 'whoa']}, + "face_with_symbols_on_mouth": {'code': '1f92c', 'aliases': ['swearing']}, + "factory": {'code': '1f3ed', 'aliases': []}, + "factory_worker": {'code': '1f9d1-200d-1f3ed', 'aliases': []}, + "fairy": {'code': '1f9da', 'aliases': []}, + "falafel": {'code': '1f9c6', 'aliases': ['chickpea', 'meatball']}, + "fallen_leaf": {'code': '1f342', 'aliases': []}, + "family": {'code': '1f46a', 'aliases': []}, + "family_man_boy": {'code': '1f468-200d-1f466', 'aliases': []}, + "family_man_boy_boy": {'code': '1f468-200d-1f466-200d-1f466', 'aliases': []}, + "family_man_girl": {'code': '1f468-200d-1f467', 'aliases': []}, + "family_man_girl_boy": {'code': '1f468-200d-1f467-200d-1f466', 'aliases': []}, + "family_man_girl_girl": {'code': '1f468-200d-1f467-200d-1f467', 'aliases': []}, + "family_man_man_boy": {'code': '1f468-200d-1f468-200d-1f466', 'aliases': []}, + "family_man_man_boy_boy": {'code': '1f468-200d-1f468-200d-1f466-200d-1f466', 'aliases': []}, + "family_man_man_girl": {'code': '1f468-200d-1f468-200d-1f467', 'aliases': []}, + "family_man_man_girl_boy": {'code': '1f468-200d-1f468-200d-1f467-200d-1f466', 'aliases': []}, + "family_man_man_girl_girl": {'code': '1f468-200d-1f468-200d-1f467-200d-1f467', 'aliases': []}, + "family_man_woman_boy": {'code': '1f468-200d-1f469-200d-1f466', 'aliases': []}, + "family_man_woman_boy_boy": {'code': '1f468-200d-1f469-200d-1f466-200d-1f466', 'aliases': []}, + "family_man_woman_girl": {'code': '1f468-200d-1f469-200d-1f467', 'aliases': []}, + "family_man_woman_girl_boy": {'code': '1f468-200d-1f469-200d-1f467-200d-1f466', 'aliases': []}, + "family_man_woman_girl_girl": {'code': '1f468-200d-1f469-200d-1f467-200d-1f467', 'aliases': []}, + "family_woman_boy": {'code': '1f469-200d-1f466', 'aliases': []}, + "family_woman_boy_boy": {'code': '1f469-200d-1f466-200d-1f466', 'aliases': []}, + "family_woman_girl": {'code': '1f469-200d-1f467', 'aliases': []}, + "family_woman_girl_boy": {'code': '1f469-200d-1f467-200d-1f466', 'aliases': []}, + "family_woman_girl_girl": {'code': '1f469-200d-1f467-200d-1f467', 'aliases': []}, + "family_woman_woman_boy": {'code': '1f469-200d-1f469-200d-1f466', 'aliases': []}, + "family_woman_woman_boy_boy": {'code': '1f469-200d-1f469-200d-1f466-200d-1f466', 'aliases': []}, + "family_woman_woman_girl": {'code': '1f469-200d-1f469-200d-1f467', 'aliases': []}, + "family_woman_woman_girl_boy": {'code': '1f469-200d-1f469-200d-1f467-200d-1f466', 'aliases': []}, + "family_woman_woman_girl_girl": {'code': '1f469-200d-1f469-200d-1f467-200d-1f467', 'aliases': []}, + "farmer": {'code': '1f9d1-200d-1f33e', 'aliases': []}, + "fast_forward": {'code': '23e9', 'aliases': []}, + "fax": {'code': '1f4e0', 'aliases': []}, + "fear": {'code': '1f628', 'aliases': ['scared', 'shock']}, + "feather": {'code': '1fab6', 'aliases': ['flight', 'light', 'plumage']}, + "female_sign": {'code': '2640', 'aliases': []}, + "fencing": {'code': '1f93a', 'aliases': []}, + "ferris_wheel": {'code': '1f3a1', 'aliases': []}, + "ferry": {'code': '26f4', 'aliases': []}, + "field_hockey": {'code': '1f3d1', 'aliases': []}, + "file_cabinet": {'code': '1f5c4', 'aliases': []}, + "film": {'code': '1f39e', 'aliases': []}, + "fingers_crossed": {'code': '1f91e', 'aliases': []}, + "fire": {'code': '1f525', 'aliases': ['lit', 'hot', 'flame']}, + "fire_extinguisher": {'code': '1f9ef', 'aliases': ['extinguish', 'quench']}, + "fire_truck": {'code': '1f692', 'aliases': ['fire_engine']}, + "firecracker": {'code': '1f9e8', 'aliases': ['dynamite', 'explosive']}, + "firefighter": {'code': '1f9d1-200d-1f692', 'aliases': []}, + "fireworks": {'code': '1f386', 'aliases': []}, + "first_place": {'code': '1f947', 'aliases': ['gold', 'number_one']}, + "first_quarter_moon": {'code': '1f313', 'aliases': []}, + "fish": {'code': '1f41f', 'aliases': []}, + "fishing": {'code': '1f3a3', 'aliases': []}, + "fist": {'code': '270a', 'aliases': ['power']}, + "fist_bump": {'code': '1f44a', 'aliases': ['punch']}, + "five": {'code': '0035-20e3', 'aliases': []}, + "fixing": {'code': '1f527', 'aliases': ['wrench']}, + "flag_afghanistan": {'code': '1f1e6-1f1eb', 'aliases': []}, + "flag_albania": {'code': '1f1e6-1f1f1', 'aliases': []}, + "flag_algeria": {'code': '1f1e9-1f1ff', 'aliases': []}, + "flag_american_samoa": {'code': '1f1e6-1f1f8', 'aliases': []}, + "flag_andorra": {'code': '1f1e6-1f1e9', 'aliases': []}, + "flag_angola": {'code': '1f1e6-1f1f4', 'aliases': []}, + "flag_anguilla": {'code': '1f1e6-1f1ee', 'aliases': []}, + "flag_antarctica": {'code': '1f1e6-1f1f6', 'aliases': []}, + "flag_antigua_and_barbuda": {'code': '1f1e6-1f1ec', 'aliases': []}, + "flag_argentina": {'code': '1f1e6-1f1f7', 'aliases': []}, + "flag_armenia": {'code': '1f1e6-1f1f2', 'aliases': []}, + "flag_aruba": {'code': '1f1e6-1f1fc', 'aliases': []}, + "flag_ascension_island": {'code': '1f1e6-1f1e8', 'aliases': []}, + "flag_australia": {'code': '1f1e6-1f1fa', 'aliases': []}, + "flag_austria": {'code': '1f1e6-1f1f9', 'aliases': []}, + "flag_azerbaijan": {'code': '1f1e6-1f1ff', 'aliases': []}, + "flag_bahamas": {'code': '1f1e7-1f1f8', 'aliases': []}, + "flag_bahrain": {'code': '1f1e7-1f1ed', 'aliases': []}, + "flag_bangladesh": {'code': '1f1e7-1f1e9', 'aliases': []}, + "flag_barbados": {'code': '1f1e7-1f1e7', 'aliases': []}, + "flag_belarus": {'code': '1f1e7-1f1fe', 'aliases': []}, + "flag_belgium": {'code': '1f1e7-1f1ea', 'aliases': []}, + "flag_belize": {'code': '1f1e7-1f1ff', 'aliases': []}, + "flag_benin": {'code': '1f1e7-1f1ef', 'aliases': []}, + "flag_bermuda": {'code': '1f1e7-1f1f2', 'aliases': []}, + "flag_bhutan": {'code': '1f1e7-1f1f9', 'aliases': []}, + "flag_bolivia": {'code': '1f1e7-1f1f4', 'aliases': []}, + "flag_bosnia_and_herzegovina": {'code': '1f1e7-1f1e6', 'aliases': []}, + "flag_botswana": {'code': '1f1e7-1f1fc', 'aliases': []}, + "flag_bouvet_island": {'code': '1f1e7-1f1fb', 'aliases': []}, + "flag_brazil": {'code': '1f1e7-1f1f7', 'aliases': []}, + "flag_british_indian_ocean_territory": {'code': '1f1ee-1f1f4', 'aliases': []}, + "flag_british_virgin_islands": {'code': '1f1fb-1f1ec', 'aliases': []}, + "flag_brunei": {'code': '1f1e7-1f1f3', 'aliases': []}, + "flag_bulgaria": {'code': '1f1e7-1f1ec', 'aliases': []}, + "flag_burkina_faso": {'code': '1f1e7-1f1eb', 'aliases': []}, + "flag_burundi": {'code': '1f1e7-1f1ee', 'aliases': []}, + "flag_cambodia": {'code': '1f1f0-1f1ed', 'aliases': []}, + "flag_cameroon": {'code': '1f1e8-1f1f2', 'aliases': []}, + "flag_canada": {'code': '1f1e8-1f1e6', 'aliases': []}, + "flag_canary_islands": {'code': '1f1ee-1f1e8', 'aliases': []}, + "flag_cape_verde": {'code': '1f1e8-1f1fb', 'aliases': []}, + "flag_caribbean_netherlands": {'code': '1f1e7-1f1f6', 'aliases': []}, + "flag_cayman_islands": {'code': '1f1f0-1f1fe', 'aliases': []}, + "flag_central_african_republic": {'code': '1f1e8-1f1eb', 'aliases': []}, + "flag_ceuta_and_melilla": {'code': '1f1ea-1f1e6', 'aliases': []}, + "flag_chad": {'code': '1f1f9-1f1e9', 'aliases': []}, + "flag_chile": {'code': '1f1e8-1f1f1', 'aliases': []}, + "flag_china": {'code': '1f1e8-1f1f3', 'aliases': []}, + "flag_christmas_island": {'code': '1f1e8-1f1fd', 'aliases': []}, + "flag_clipperton_island": {'code': '1f1e8-1f1f5', 'aliases': []}, + "flag_cocos_keeling_islands": {'code': '1f1e8-1f1e8', 'aliases': []}, + "flag_colombia": {'code': '1f1e8-1f1f4', 'aliases': []}, + "flag_comoros": {'code': '1f1f0-1f1f2', 'aliases': []}, + "flag_congo_brazzaville": {'code': '1f1e8-1f1ec', 'aliases': []}, + "flag_congo_kinshasa": {'code': '1f1e8-1f1e9', 'aliases': []}, + "flag_cook_islands": {'code': '1f1e8-1f1f0', 'aliases': []}, + "flag_costa_rica": {'code': '1f1e8-1f1f7', 'aliases': []}, + "flag_croatia": {'code': '1f1ed-1f1f7', 'aliases': []}, + "flag_cuba": {'code': '1f1e8-1f1fa', 'aliases': []}, + "flag_curaçao": {'code': '1f1e8-1f1fc', 'aliases': ['flag_curacao']}, + "flag_cyprus": {'code': '1f1e8-1f1fe', 'aliases': []}, + "flag_czechia": {'code': '1f1e8-1f1ff', 'aliases': []}, + "flag_côte_divoire": {'code': '1f1e8-1f1ee', 'aliases': ['flag_cote_divoire']}, + "flag_denmark": {'code': '1f1e9-1f1f0', 'aliases': []}, + "flag_diego_garcia": {'code': '1f1e9-1f1ec', 'aliases': []}, + "flag_djibouti": {'code': '1f1e9-1f1ef', 'aliases': []}, + "flag_dominica": {'code': '1f1e9-1f1f2', 'aliases': []}, + "flag_dominican_republic": {'code': '1f1e9-1f1f4', 'aliases': []}, + "flag_ecuador": {'code': '1f1ea-1f1e8', 'aliases': []}, + "flag_egypt": {'code': '1f1ea-1f1ec', 'aliases': []}, + "flag_el_salvador": {'code': '1f1f8-1f1fb', 'aliases': []}, + "flag_england": {'code': '1f3f4-e0067-e0062-e0065-e006e-e0067-e007f', 'aliases': []}, + "flag_equatorial_guinea": {'code': '1f1ec-1f1f6', 'aliases': []}, + "flag_eritrea": {'code': '1f1ea-1f1f7', 'aliases': []}, + "flag_estonia": {'code': '1f1ea-1f1ea', 'aliases': []}, + "flag_eswatini": {'code': '1f1f8-1f1ff', 'aliases': []}, + "flag_ethiopia": {'code': '1f1ea-1f1f9', 'aliases': []}, + "flag_european_union": {'code': '1f1ea-1f1fa', 'aliases': []}, + "flag_falkland_islands": {'code': '1f1eb-1f1f0', 'aliases': []}, + "flag_faroe_islands": {'code': '1f1eb-1f1f4', 'aliases': []}, + "flag_fiji": {'code': '1f1eb-1f1ef', 'aliases': []}, + "flag_finland": {'code': '1f1eb-1f1ee', 'aliases': []}, + "flag_france": {'code': '1f1eb-1f1f7', 'aliases': []}, + "flag_french_guiana": {'code': '1f1ec-1f1eb', 'aliases': []}, + "flag_french_polynesia": {'code': '1f1f5-1f1eb', 'aliases': []}, + "flag_french_southern_territories": {'code': '1f1f9-1f1eb', 'aliases': []}, + "flag_gabon": {'code': '1f1ec-1f1e6', 'aliases': []}, + "flag_gambia": {'code': '1f1ec-1f1f2', 'aliases': []}, + "flag_georgia": {'code': '1f1ec-1f1ea', 'aliases': []}, + "flag_germany": {'code': '1f1e9-1f1ea', 'aliases': []}, + "flag_ghana": {'code': '1f1ec-1f1ed', 'aliases': []}, + "flag_gibraltar": {'code': '1f1ec-1f1ee', 'aliases': []}, + "flag_greece": {'code': '1f1ec-1f1f7', 'aliases': []}, + "flag_greenland": {'code': '1f1ec-1f1f1', 'aliases': []}, + "flag_grenada": {'code': '1f1ec-1f1e9', 'aliases': []}, + "flag_guadeloupe": {'code': '1f1ec-1f1f5', 'aliases': []}, + "flag_guam": {'code': '1f1ec-1f1fa', 'aliases': []}, + "flag_guatemala": {'code': '1f1ec-1f1f9', 'aliases': []}, + "flag_guernsey": {'code': '1f1ec-1f1ec', 'aliases': []}, + "flag_guinea": {'code': '1f1ec-1f1f3', 'aliases': []}, + "flag_guinea_bissau": {'code': '1f1ec-1f1fc', 'aliases': []}, + "flag_guyana": {'code': '1f1ec-1f1fe', 'aliases': []}, + "flag_haiti": {'code': '1f1ed-1f1f9', 'aliases': []}, + "flag_heard_and_mcdonald_islands": {'code': '1f1ed-1f1f2', 'aliases': []}, + "flag_honduras": {'code': '1f1ed-1f1f3', 'aliases': []}, + "flag_hong_kong_sar_china": {'code': '1f1ed-1f1f0', 'aliases': []}, + "flag_hungary": {'code': '1f1ed-1f1fa', 'aliases': []}, + "flag_iceland": {'code': '1f1ee-1f1f8', 'aliases': []}, + "flag_india": {'code': '1f1ee-1f1f3', 'aliases': []}, + "flag_indonesia": {'code': '1f1ee-1f1e9', 'aliases': []}, + "flag_iran": {'code': '1f1ee-1f1f7', 'aliases': []}, + "flag_iraq": {'code': '1f1ee-1f1f6', 'aliases': []}, + "flag_ireland": {'code': '1f1ee-1f1ea', 'aliases': []}, + "flag_isle_of_man": {'code': '1f1ee-1f1f2', 'aliases': []}, + "flag_israel": {'code': '1f1ee-1f1f1', 'aliases': []}, + "flag_italy": {'code': '1f1ee-1f1f9', 'aliases': []}, + "flag_jamaica": {'code': '1f1ef-1f1f2', 'aliases': []}, + "flag_japan": {'code': '1f1ef-1f1f5', 'aliases': []}, + "flag_jersey": {'code': '1f1ef-1f1ea', 'aliases': []}, + "flag_jordan": {'code': '1f1ef-1f1f4', 'aliases': []}, + "flag_kazakhstan": {'code': '1f1f0-1f1ff', 'aliases': []}, + "flag_kenya": {'code': '1f1f0-1f1ea', 'aliases': []}, + "flag_kiribati": {'code': '1f1f0-1f1ee', 'aliases': []}, + "flag_kosovo": {'code': '1f1fd-1f1f0', 'aliases': []}, + "flag_kuwait": {'code': '1f1f0-1f1fc', 'aliases': []}, + "flag_kyrgyzstan": {'code': '1f1f0-1f1ec', 'aliases': []}, + "flag_laos": {'code': '1f1f1-1f1e6', 'aliases': []}, + "flag_latvia": {'code': '1f1f1-1f1fb', 'aliases': []}, + "flag_lebanon": {'code': '1f1f1-1f1e7', 'aliases': []}, + "flag_lesotho": {'code': '1f1f1-1f1f8', 'aliases': []}, + "flag_liberia": {'code': '1f1f1-1f1f7', 'aliases': []}, + "flag_libya": {'code': '1f1f1-1f1fe', 'aliases': []}, + "flag_liechtenstein": {'code': '1f1f1-1f1ee', 'aliases': []}, + "flag_lithuania": {'code': '1f1f1-1f1f9', 'aliases': []}, + "flag_luxembourg": {'code': '1f1f1-1f1fa', 'aliases': []}, + "flag_macao_sar_china": {'code': '1f1f2-1f1f4', 'aliases': []}, + "flag_madagascar": {'code': '1f1f2-1f1ec', 'aliases': []}, + "flag_malawi": {'code': '1f1f2-1f1fc', 'aliases': []}, + "flag_malaysia": {'code': '1f1f2-1f1fe', 'aliases': []}, + "flag_maldives": {'code': '1f1f2-1f1fb', 'aliases': []}, + "flag_mali": {'code': '1f1f2-1f1f1', 'aliases': []}, + "flag_malta": {'code': '1f1f2-1f1f9', 'aliases': []}, + "flag_marshall_islands": {'code': '1f1f2-1f1ed', 'aliases': []}, + "flag_martinique": {'code': '1f1f2-1f1f6', 'aliases': []}, + "flag_mauritania": {'code': '1f1f2-1f1f7', 'aliases': []}, + "flag_mauritius": {'code': '1f1f2-1f1fa', 'aliases': []}, + "flag_mayotte": {'code': '1f1fe-1f1f9', 'aliases': []}, + "flag_mexico": {'code': '1f1f2-1f1fd', 'aliases': []}, + "flag_micronesia": {'code': '1f1eb-1f1f2', 'aliases': []}, + "flag_moldova": {'code': '1f1f2-1f1e9', 'aliases': []}, + "flag_monaco": {'code': '1f1f2-1f1e8', 'aliases': []}, + "flag_mongolia": {'code': '1f1f2-1f1f3', 'aliases': []}, + "flag_montenegro": {'code': '1f1f2-1f1ea', 'aliases': []}, + "flag_montserrat": {'code': '1f1f2-1f1f8', 'aliases': []}, + "flag_morocco": {'code': '1f1f2-1f1e6', 'aliases': []}, + "flag_mozambique": {'code': '1f1f2-1f1ff', 'aliases': []}, + "flag_myanmar_burma": {'code': '1f1f2-1f1f2', 'aliases': []}, + "flag_namibia": {'code': '1f1f3-1f1e6', 'aliases': []}, + "flag_nauru": {'code': '1f1f3-1f1f7', 'aliases': []}, + "flag_nepal": {'code': '1f1f3-1f1f5', 'aliases': []}, + "flag_netherlands": {'code': '1f1f3-1f1f1', 'aliases': []}, + "flag_new_caledonia": {'code': '1f1f3-1f1e8', 'aliases': []}, + "flag_new_zealand": {'code': '1f1f3-1f1ff', 'aliases': []}, + "flag_nicaragua": {'code': '1f1f3-1f1ee', 'aliases': []}, + "flag_niger": {'code': '1f1f3-1f1ea', 'aliases': []}, + "flag_nigeria": {'code': '1f1f3-1f1ec', 'aliases': []}, + "flag_niue": {'code': '1f1f3-1f1fa', 'aliases': []}, + "flag_norfolk_island": {'code': '1f1f3-1f1eb', 'aliases': []}, + "flag_north_korea": {'code': '1f1f0-1f1f5', 'aliases': []}, + "flag_north_macedonia": {'code': '1f1f2-1f1f0', 'aliases': []}, + "flag_northern_mariana_islands": {'code': '1f1f2-1f1f5', 'aliases': []}, + "flag_norway": {'code': '1f1f3-1f1f4', 'aliases': []}, + "flag_oman": {'code': '1f1f4-1f1f2', 'aliases': []}, + "flag_pakistan": {'code': '1f1f5-1f1f0', 'aliases': []}, + "flag_palau": {'code': '1f1f5-1f1fc', 'aliases': []}, + "flag_palestinian_territories": {'code': '1f1f5-1f1f8', 'aliases': []}, + "flag_panama": {'code': '1f1f5-1f1e6', 'aliases': []}, + "flag_papua_new_guinea": {'code': '1f1f5-1f1ec', 'aliases': []}, + "flag_paraguay": {'code': '1f1f5-1f1fe', 'aliases': []}, + "flag_peru": {'code': '1f1f5-1f1ea', 'aliases': []}, + "flag_philippines": {'code': '1f1f5-1f1ed', 'aliases': []}, + "flag_pitcairn_islands": {'code': '1f1f5-1f1f3', 'aliases': []}, + "flag_poland": {'code': '1f1f5-1f1f1', 'aliases': []}, + "flag_portugal": {'code': '1f1f5-1f1f9', 'aliases': []}, + "flag_puerto_rico": {'code': '1f1f5-1f1f7', 'aliases': []}, + "flag_qatar": {'code': '1f1f6-1f1e6', 'aliases': []}, + "flag_romania": {'code': '1f1f7-1f1f4', 'aliases': []}, + "flag_russia": {'code': '1f1f7-1f1fa', 'aliases': []}, + "flag_rwanda": {'code': '1f1f7-1f1fc', 'aliases': []}, + "flag_réunion": {'code': '1f1f7-1f1ea', 'aliases': ['flag_reunion']}, + "flag_samoa": {'code': '1f1fc-1f1f8', 'aliases': []}, + "flag_san_marino": {'code': '1f1f8-1f1f2', 'aliases': []}, + "flag_saudi_arabia": {'code': '1f1f8-1f1e6', 'aliases': []}, + "flag_scotland": {'code': '1f3f4-e0067-e0062-e0073-e0063-e0074-e007f', 'aliases': []}, + "flag_senegal": {'code': '1f1f8-1f1f3', 'aliases': []}, + "flag_serbia": {'code': '1f1f7-1f1f8', 'aliases': []}, + "flag_seychelles": {'code': '1f1f8-1f1e8', 'aliases': []}, + "flag_sierra_leone": {'code': '1f1f8-1f1f1', 'aliases': []}, + "flag_singapore": {'code': '1f1f8-1f1ec', 'aliases': []}, + "flag_sint_maarten": {'code': '1f1f8-1f1fd', 'aliases': []}, + "flag_slovakia": {'code': '1f1f8-1f1f0', 'aliases': []}, + "flag_slovenia": {'code': '1f1f8-1f1ee', 'aliases': []}, + "flag_solomon_islands": {'code': '1f1f8-1f1e7', 'aliases': []}, + "flag_somalia": {'code': '1f1f8-1f1f4', 'aliases': []}, + "flag_south_africa": {'code': '1f1ff-1f1e6', 'aliases': []}, + "flag_south_georgia_and_south_sandwich_islands": {'code': '1f1ec-1f1f8', 'aliases': []}, + "flag_south_korea": {'code': '1f1f0-1f1f7', 'aliases': []}, + "flag_south_sudan": {'code': '1f1f8-1f1f8', 'aliases': []}, + "flag_spain": {'code': '1f1ea-1f1f8', 'aliases': []}, + "flag_sri_lanka": {'code': '1f1f1-1f1f0', 'aliases': []}, + "flag_st_barthélemy": {'code': '1f1e7-1f1f1', 'aliases': ['flag_st_barthelemy']}, + "flag_st_helena": {'code': '1f1f8-1f1ed', 'aliases': []}, + "flag_st_kitts_and_nevis": {'code': '1f1f0-1f1f3', 'aliases': []}, + "flag_st_lucia": {'code': '1f1f1-1f1e8', 'aliases': []}, + "flag_st_martin": {'code': '1f1f2-1f1eb', 'aliases': []}, + "flag_st_pierre_and_miquelon": {'code': '1f1f5-1f1f2', 'aliases': []}, + "flag_st_vincent_and_grenadines": {'code': '1f1fb-1f1e8', 'aliases': []}, + "flag_sudan": {'code': '1f1f8-1f1e9', 'aliases': []}, + "flag_suriname": {'code': '1f1f8-1f1f7', 'aliases': []}, + "flag_svalbard_and_jan_mayen": {'code': '1f1f8-1f1ef', 'aliases': []}, + "flag_sweden": {'code': '1f1f8-1f1ea', 'aliases': []}, + "flag_switzerland": {'code': '1f1e8-1f1ed', 'aliases': []}, + "flag_syria": {'code': '1f1f8-1f1fe', 'aliases': []}, + "flag_são_tomé_and_príncipe": {'code': '1f1f8-1f1f9', 'aliases': ['flag_sao_tome_and_principe']}, + "flag_taiwan": {'code': '1f1f9-1f1fc', 'aliases': []}, + "flag_tajikistan": {'code': '1f1f9-1f1ef', 'aliases': []}, + "flag_tanzania": {'code': '1f1f9-1f1ff', 'aliases': []}, + "flag_thailand": {'code': '1f1f9-1f1ed', 'aliases': []}, + "flag_timor_leste": {'code': '1f1f9-1f1f1', 'aliases': []}, + "flag_togo": {'code': '1f1f9-1f1ec', 'aliases': []}, + "flag_tokelau": {'code': '1f1f9-1f1f0', 'aliases': []}, + "flag_tonga": {'code': '1f1f9-1f1f4', 'aliases': []}, + "flag_trinidad_and_tobago": {'code': '1f1f9-1f1f9', 'aliases': []}, + "flag_tristan_da_cunha": {'code': '1f1f9-1f1e6', 'aliases': []}, + "flag_tunisia": {'code': '1f1f9-1f1f3', 'aliases': []}, + "flag_turkey": {'code': '1f1f9-1f1f7', 'aliases': []}, + "flag_turkmenistan": {'code': '1f1f9-1f1f2', 'aliases': []}, + "flag_turks_and_caicos_islands": {'code': '1f1f9-1f1e8', 'aliases': []}, + "flag_tuvalu": {'code': '1f1f9-1f1fb', 'aliases': []}, + "flag_uganda": {'code': '1f1fa-1f1ec', 'aliases': []}, + "flag_ukraine": {'code': '1f1fa-1f1e6', 'aliases': []}, + "flag_united_arab_emirates": {'code': '1f1e6-1f1ea', 'aliases': []}, + "flag_united_kingdom": {'code': '1f1ec-1f1e7', 'aliases': []}, + "flag_united_nations": {'code': '1f1fa-1f1f3', 'aliases': []}, + "flag_united_states": {'code': '1f1fa-1f1f8', 'aliases': []}, + "flag_uruguay": {'code': '1f1fa-1f1fe', 'aliases': []}, + "flag_us_outlying_islands": {'code': '1f1fa-1f1f2', 'aliases': []}, + "flag_us_virgin_islands": {'code': '1f1fb-1f1ee', 'aliases': []}, + "flag_uzbekistan": {'code': '1f1fa-1f1ff', 'aliases': []}, + "flag_vanuatu": {'code': '1f1fb-1f1fa', 'aliases': []}, + "flag_vatican_city": {'code': '1f1fb-1f1e6', 'aliases': []}, + "flag_venezuela": {'code': '1f1fb-1f1ea', 'aliases': []}, + "flag_vietnam": {'code': '1f1fb-1f1f3', 'aliases': []}, + "flag_wales": {'code': '1f3f4-e0067-e0062-e0077-e006c-e0073-e007f', 'aliases': []}, + "flag_wallis_and_futuna": {'code': '1f1fc-1f1eb', 'aliases': []}, + "flag_western_sahara": {'code': '1f1ea-1f1ed', 'aliases': []}, + "flag_yemen": {'code': '1f1fe-1f1ea', 'aliases': []}, + "flag_zambia": {'code': '1f1ff-1f1f2', 'aliases': []}, + "flag_zimbabwe": {'code': '1f1ff-1f1fc', 'aliases': []}, + "flag_åland_islands": {'code': '1f1e6-1f1fd', 'aliases': ['flag_aland_islands']}, + "flamingo": {'code': '1f9a9', 'aliases': ['flamboyant']}, + "flashlight": {'code': '1f526', 'aliases': []}, + "flat_shoe": {'code': '1f97f', 'aliases': ['ballet_flat', 'slip_on', 'slipper']}, + "flatbread": {'code': '1fad3', 'aliases': ['arepa', 'lavash', 'naan', 'pita']}, + "fleur_de_lis": {'code': '269c', 'aliases': []}, + "floppy_disk": {'code': '1f4be', 'aliases': []}, + "flushed": {'code': '1f633', 'aliases': ['embarrassed', 'blushing']}, + "fly": {'code': '1fab0', 'aliases': ['maggot', 'rotting']}, + "flying_disc": {'code': '1f94f', 'aliases': ['ultimate']}, + "flying_saucer": {'code': '1f6f8', 'aliases': []}, + "fog": {'code': '1f32b', 'aliases': ['hazy']}, + "foggy": {'code': '1f301', 'aliases': []}, + "folder": {'code': '1f4c2', 'aliases': []}, + "fondue": {'code': '1fad5', 'aliases': ['melted', 'swiss']}, + "food": {'code': '1f372', 'aliases': ['soup', 'stew']}, + "foot": {'code': '1f9b6', 'aliases': ['stomp']}, + "football": {'code': '26bd', 'aliases': ['soccer']}, + "footprints": {'code': '1f463', 'aliases': ['feet']}, + "fork_and_knife": {'code': '1f374', 'aliases': ['eating_utensils']}, + "fortune_cookie": {'code': '1f960', 'aliases': ['prophecy']}, + "forward": {'code': '21aa', 'aliases': ['right_hook']}, + "fountain": {'code': '26f2', 'aliases': []}, + "fountain_pen": {'code': '1f58b', 'aliases': []}, + "four": {'code': '0034-20e3', 'aliases': []}, + "fox": {'code': '1f98a', 'aliases': []}, + "free": {'code': '1f193', 'aliases': []}, + "fries": {'code': '1f35f', 'aliases': []}, + "frog": {'code': '1f438', 'aliases': []}, + "frosty": {'code': '26c4', 'aliases': []}, + "frown": {'code': '1f641', 'aliases': ['slight_frown']}, + "frowning": {'code': '1f626', 'aliases': []}, + "fuel_pump": {'code': '26fd', 'aliases': ['gas_pump', 'petrol_pump']}, + "full_moon": {'code': '1f315', 'aliases': []}, + "funeral_urn": {'code': '26b1', 'aliases': ['cremation']}, + "garlic": {'code': '1f9c4', 'aliases': []}, + "gear": {'code': '2699', 'aliases': ['settings', 'mechanical', 'engineer']}, + "gem": {'code': '1f48e', 'aliases': ['crystal']}, + "gemini": {'code': '264a', 'aliases': []}, + "genie": {'code': '1f9de', 'aliases': []}, + "ghost": {'code': '1f47b', 'aliases': ['boo', 'spooky', 'haunted']}, + "gift": {'code': '1f381', 'aliases': ['present']}, + "gift_heart": {'code': '1f49d', 'aliases': []}, + "giraffe": {'code': '1f992', 'aliases': ['spots']}, + "girl": {'code': '1f467', 'aliases': []}, + "glasses": {'code': '1f453', 'aliases': ['spectacles']}, + "gloves": {'code': '1f9e4', 'aliases': []}, + "glowing_star": {'code': '1f31f', 'aliases': []}, + "goat": {'code': '1f410', 'aliases': []}, + "goblin": {'code': '1f47a', 'aliases': []}, + "goggles": {'code': '1f97d', 'aliases': ['eye_protection', 'swimming', 'welding']}, + "gold_record": {'code': '1f4bd', 'aliases': ['minidisc']}, + "golf": {'code': '1f3cc', 'aliases': []}, + "gondola": {'code': '1f6a0', 'aliases': ['mountain_cableway']}, + "goodnight": {'code': '1f31b', 'aliases': []}, + "gooooooooal": {'code': '1f945', 'aliases': ['goal']}, + "gorilla": {'code': '1f98d', 'aliases': []}, + "graduate": {'code': '1f393', 'aliases': ['mortar_board']}, + "grapes": {'code': '1f347', 'aliases': []}, + "green_apple": {'code': '1f34f', 'aliases': []}, + "green_book": {'code': '1f4d7', 'aliases': []}, + "green_circle": {'code': '1f7e2', 'aliases': ['green']}, + "green_heart": {'code': '1f49a', 'aliases': ['envy']}, + "green_large_square": {'code': '1f7e9', 'aliases': []}, + "grey_exclamation": {'code': '2755', 'aliases': []}, + "grey_question": {'code': '2754', 'aliases': []}, + "grimacing": {'code': '1f62c', 'aliases': ['nervous', 'anxious']}, + "grinning": {'code': '1f600', 'aliases': ['happy']}, + "grinning_face_with_smiling_eyes": {'code': '1f601', 'aliases': []}, + "gua_pi_mao": {'code': '1f472', 'aliases': []}, + "guard": {'code': '1f482', 'aliases': []}, + "guide_dog": {'code': '1f9ae', 'aliases': ['guide']}, + "guitar": {'code': '1f3b8', 'aliases': []}, + "gun": {'code': '1f52b', 'aliases': []}, + "haircut": {'code': '1f487', 'aliases': []}, + "hamburger": {'code': '1f354', 'aliases': []}, + "hammer": {'code': '1f528', 'aliases': ['maintenance', 'handyman', 'handywoman']}, + "hamsa": {'code': '1faac', 'aliases': ['amulet', 'fatima', 'mary', 'miriam', 'protection']}, + "hamster": {'code': '1f439', 'aliases': []}, + "hand": {'code': '270b', 'aliases': ['raised_hand']}, + "hand_with_index_finger_and_thumb_crossed": {'code': '1faf0', 'aliases': ['expensive', 'snap']}, + "handbag": {'code': '1f45c', 'aliases': []}, + "handball": {'code': '1f93e', 'aliases': []}, + "handshake": {'code': '1f91d', 'aliases': ['done_deal']}, + "harvest": {'code': '1f33e', 'aliases': ['ear_of_rice']}, + "hash": {'code': '0023-20e3', 'aliases': []}, + "hat": {'code': '1f452', 'aliases': []}, + "hatching": {'code': '1f423', 'aliases': ['hatching_chick']}, + "heading_down": {'code': '2935', 'aliases': []}, + "heading_up": {'code': '2934', 'aliases': []}, + "headlines": {'code': '1f4f0', 'aliases': []}, + "headphones": {'code': '1f3a7', 'aliases': []}, + "headstone": {'code': '1faa6', 'aliases': ['cemetery', 'graveyard', 'tombstone']}, + "health_worker": {'code': '1f9d1-200d-2695', 'aliases': []}, + "hear_no_evil": {'code': '1f649', 'aliases': []}, + "heart": {'code': '2764', 'aliases': ['love', 'love_you']}, + "heart_box": {'code': '1f49f', 'aliases': []}, + "heart_exclamation": {'code': '2763', 'aliases': []}, + "heart_eyes": {'code': '1f60d', 'aliases': ['in_love']}, + "heart_eyes_cat": {'code': '1f63b', 'aliases': []}, + "heart_hands": {'code': '1faf6', 'aliases': []}, + "heart_kiss": {'code': '1f618', 'aliases': ['blow_a_kiss']}, + "heart_on_fire": {'code': '2764-200d-1f525', 'aliases': ['burn', 'lust', 'sacred_heart']}, + "heart_pulse": {'code': '1f497', 'aliases': ['growing_heart']}, + "heartbeat": {'code': '1f493', 'aliases': []}, + "hearts": {'code': '2665', 'aliases': []}, + "heavy_equals_sign": {'code': '1f7f0', 'aliases': ['equality', 'math']}, + "hedgehog": {'code': '1f994', 'aliases': ['spiny']}, + "helicopter": {'code': '1f681', 'aliases': []}, + "helmet": {'code': '26d1', 'aliases': ['hard_hat', 'rescue_worker', 'safety_first', 'invincible']}, + "herb": {'code': '1f33f', 'aliases': ['plant']}, + "hibiscus": {'code': '1f33a', 'aliases': []}, + "high_five": {'code': '1f590', 'aliases': ['palm']}, + "high_heels": {'code': '1f460', 'aliases': []}, + "high_speed_train": {'code': '1f684', 'aliases': []}, + "high_voltage": {'code': '26a1', 'aliases': ['zap']}, + "hiking_boot": {'code': '1f97e', 'aliases': ['backpacking', 'hiking']}, + "hindu_temple": {'code': '1f6d5', 'aliases': ['hindu', 'temple']}, + "hippopotamus": {'code': '1f99b', 'aliases': ['hippo']}, + "hole": {'code': '1f573', 'aliases': []}, + "hole_in_one": {'code': '26f3', 'aliases': []}, + "holiday_tree": {'code': '1f384', 'aliases': []}, + "honey": {'code': '1f36f', 'aliases': []}, + "hook": {'code': '1fa9d', 'aliases': ['crook', 'curve', 'ensnare', 'selling_point']}, + "horizontal_traffic_light": {'code': '1f6a5', 'aliases': []}, + "horn": {'code': '1f4ef', 'aliases': []}, + "horse": {'code': '1f40e', 'aliases': []}, + "horse_racing": {'code': '1f3c7', 'aliases': ['horse_riding']}, + "hospital": {'code': '1f3e5', 'aliases': []}, + "hot_face": {'code': '1f975', 'aliases': ['feverish', 'heat_stroke', 'red_faced', 'sweating']}, + "hot_pepper": {'code': '1f336', 'aliases': ['chili_pepper']}, + "hot_springs": {'code': '2668', 'aliases': []}, + "hotdog": {'code': '1f32d', 'aliases': []}, + "hotel": {'code': '1f3e8', 'aliases': []}, + "house": {'code': '1f3e0', 'aliases': []}, + "houses": {'code': '1f3d8', 'aliases': []}, + "hug": {'code': '1f917', 'aliases': ['arms_open']}, + "humpback_whale": {'code': '1f40b', 'aliases': []}, + "hungry": {'code': '1f37d', 'aliases': ['meal', 'table_setting', 'fork_and_knife_with_plate', 'lets_eat']}, + "hurt": {'code': '1f915', 'aliases': ['head_bandage', 'injured']}, + "hushed": {'code': '1f62f', 'aliases': []}, + "hut": {'code': '1f6d6', 'aliases': ['roundhouse', 'yurt']}, + "ice": {'code': '1f9ca', 'aliases': ['ice_cube', 'iceberg']}, + "ice_cream": {'code': '1f368', 'aliases': ['gelato']}, + "ice_hockey": {'code': '1f3d2', 'aliases': []}, + "ice_skate": {'code': '26f8', 'aliases': []}, + "id": {'code': '1f194', 'aliases': []}, + "identification_card": {'code': '1faaa', 'aliases': ['credentials', 'license', 'security']}, + "in_bed": {'code': '1f6cc', 'aliases': ['accommodations', 'guestrooms']}, + "inbox": {'code': '1f4e5', 'aliases': []}, + "inbox_zero": {'code': '1f4ed', 'aliases': ['empty_mailbox', 'no_mail']}, + "index_pointing_at_the_viewer": {'code': '1faf5', 'aliases': ['point', 'you']}, + "infinity": {'code': '267e', 'aliases': ['forever', 'unbounded', 'universal']}, + "info": {'code': '2139', 'aliases': []}, + "information_desk_person": {'code': '1f481', 'aliases': ['person_tipping_hand']}, + "injection": {'code': '1f489', 'aliases': ['syringe']}, + "innocent": {'code': '1f607', 'aliases': ['halo']}, + "interrobang": {'code': '2049', 'aliases': []}, + "island": {'code': '1f3dd', 'aliases': []}, + "jack-o-lantern": {'code': '1f383', 'aliases': ['pumpkin']}, + "japan": {'code': '1f5fe', 'aliases': []}, + "japan_post": {'code': '1f3e3', 'aliases': []}, + "japanese_acceptable_button": {'code': '1f251', 'aliases': ['accept']}, + "japanese_application_button": {'code': '1f238', 'aliases': ['u7533']}, + "japanese_bargain_button": {'code': '1f250', 'aliases': ['ideograph_advantage']}, + "japanese_congratulations_button": {'code': '3297', 'aliases': ['congratulations']}, + "japanese_discount_button": {'code': '1f239', 'aliases': ['u5272']}, + "japanese_free_of_charge_button": {'code': '1f21a', 'aliases': ['u7121']}, + "japanese_here_button": {'code': '1f201', 'aliases': ['here', 'ココ']}, + "japanese_monthly_amount_button": {'code': '1f237', 'aliases': ['u6708']}, + "japanese_no_vacancy_button": {'code': '1f235', 'aliases': ['u6e80']}, + "japanese_not_free_of_charge_button": {'code': '1f236', 'aliases': ['u6709']}, + "japanese_open_for_business_button": {'code': '1f23a', 'aliases': ['u55b6']}, + "japanese_passing_grade_button": {'code': '1f234', 'aliases': ['u5408']}, + "japanese_prohibited_button": {'code': '1f232', 'aliases': ['u7981']}, + "japanese_reserved_button": {'code': '1f22f', 'aliases': ['reserved', '指']}, + "japanese_secret_button": {'code': '3299', 'aliases': []}, + "japanese_service_charge_button": {'code': '1f202', 'aliases': ['service_charge', 'サ']}, + "japanese_vacancy_button": {'code': '1f233', 'aliases': ['vacancy', '空']}, + "jar": {'code': '1fad9', 'aliases': ['container', 'sauce', 'store']}, + "jeans": {'code': '1f456', 'aliases': ['denim']}, + "joker": {'code': '1f0cf', 'aliases': []}, + "joy": {'code': '1f602', 'aliases': ['tears', 'laughter_tears']}, + "joy_cat": {'code': '1f639', 'aliases': []}, + "joystick": {'code': '1f579', 'aliases': ['arcade']}, + "judge": {'code': '1f9d1-200d-2696', 'aliases': []}, + "juggling": {'code': '1f939', 'aliases': []}, + "justice": {'code': '2696', 'aliases': ['scales', 'balance']}, + "kaaba": {'code': '1f54b', 'aliases': []}, + "kangaroo": {'code': '1f998', 'aliases': ['joey', 'jump', 'marsupial']}, + "key": {'code': '1f511', 'aliases': []}, + "keyboard": {'code': '2328', 'aliases': []}, + "kick_scooter": {'code': '1f6f4', 'aliases': []}, + "kimono": {'code': '1f458', 'aliases': []}, + "kiss": {'code': '1f48f', 'aliases': []}, + "kiss_man_man": {'code': '1f468-200d-2764-200d-1f48b-200d-1f468', 'aliases': []}, + "kiss_smiling_eyes": {'code': '1f619', 'aliases': []}, + "kiss_with_blush": {'code': '1f61a', 'aliases': []}, + "kiss_woman_man": {'code': '1f469-200d-2764-200d-1f48b-200d-1f468', 'aliases': []}, + "kiss_woman_woman": {'code': '1f469-200d-2764-200d-1f48b-200d-1f469', 'aliases': []}, + "kissing_cat": {'code': '1f63d', 'aliases': []}, + "kissing_face": {'code': '1f617', 'aliases': []}, + "kite": {'code': '1fa81', 'aliases': ['soar']}, + "kitten": {'code': '1f431', 'aliases': []}, + "kiwi": {'code': '1f95d', 'aliases': []}, + "knife": {'code': '1f52a', 'aliases': ['hocho', 'betrayed']}, + "knot": {'code': '1faa2', 'aliases': ['rope', 'tangled', 'twine', 'twist']}, + "koala": {'code': '1f428', 'aliases': []}, + "lab_coat": {'code': '1f97c', 'aliases': []}, + "label": {'code': '1f3f7', 'aliases': ['tag', 'price_tag']}, + "lacrosse": {'code': '1f94d', 'aliases': []}, + "ladder": {'code': '1fa9c', 'aliases': ['climb', 'rung', 'step']}, + "lady_beetle": {'code': '1f41e', 'aliases': ['ladybird', 'ladybug']}, + "landing": {'code': '1f6ec', 'aliases': ['arrival', 'airplane_arrival']}, + "landline": {'code': '1f4de', 'aliases': ['home_phone']}, + "lantern": {'code': '1f3ee', 'aliases': ['izakaya_lantern']}, + "large_blue_diamond": {'code': '1f537', 'aliases': []}, + "large_orange_diamond": {'code': '1f536', 'aliases': []}, + "last_quarter_moon": {'code': '1f317', 'aliases': []}, + "last_quarter_moon_face": {'code': '1f31c', 'aliases': []}, + "laughing": {'code': '1f606', 'aliases': ['lol']}, + "leafy_green": {'code': '1f96c', 'aliases': ['bok_choy', 'cabbage', 'kale', 'lettuce']}, + "leaves": {'code': '1f343', 'aliases': ['wind', 'fall']}, + "ledger": {'code': '1f4d2', 'aliases': ['spiral_notebook']}, + "left": {'code': '2b05', 'aliases': ['west']}, + "left_fist": {'code': '1f91b', 'aliases': []}, + "left_right": {'code': '2194', 'aliases': ['swap']}, + "leftwards_hand": {'code': '1faf2', 'aliases': ['leftward']}, + "leg": {'code': '1f9b5', 'aliases': ['limb']}, + "lemon": {'code': '1f34b', 'aliases': []}, + "leo": {'code': '264c', 'aliases': []}, + "leopard": {'code': '1f406', 'aliases': []}, + "levitating": {'code': '1f574', 'aliases': ['hover']}, + "libra": {'code': '264e', 'aliases': []}, + "lift": {'code': '1f3cb', 'aliases': ['work_out', 'weight_lift', 'gym']}, + "light_bulb": {'code': '1f4a1', 'aliases': ['bulb', 'idea']}, + "light_rail": {'code': '1f688', 'aliases': []}, + "lightning": {'code': '1f329', 'aliases': ['lightning_storm']}, + "link": {'code': '1f517', 'aliases': []}, + "lion": {'code': '1f981', 'aliases': []}, + "lips": {'code': '1f444', 'aliases': ['mouth']}, + "lipstick": {'code': '1f484', 'aliases': []}, + "lipstick_kiss": {'code': '1f48b', 'aliases': []}, + "living_room": {'code': '1f6cb', 'aliases': ['furniture', 'couch_and_lamp', 'lifestyles']}, + "lizard": {'code': '1f98e', 'aliases': ['gecko']}, + "llama": {'code': '1f999', 'aliases': ['alpaca', 'guanaco', 'vicuña', 'wool', 'vicuna']}, + "lobster": {'code': '1f99e', 'aliases': ['bisque', 'claws', 'seafood']}, + "locked": {'code': '1f512', 'aliases': []}, + "locker": {'code': '1f6c5', 'aliases': ['locked_bag']}, + "lollipop": {'code': '1f36d', 'aliases': []}, + "long_drum": {'code': '1fa98', 'aliases': ['beat', 'conga', 'rhythm']}, + "loop": {'code': '27b0', 'aliases': []}, + "losing_money": {'code': '1f4b8', 'aliases': ['easy_come_easy_go', 'money_with_wings']}, + "lotion_bottle": {'code': '1f9f4', 'aliases': ['lotion', 'moisturizer', 'shampoo', 'sunscreen']}, + "lotus": {'code': '1fab7', 'aliases': ['purity']}, + "louder": {'code': '1f50a', 'aliases': ['sound']}, + "loudspeaker": {'code': '1f4e2', 'aliases': ['bullhorn']}, + "love_hotel": {'code': '1f3e9', 'aliases': []}, + "love_letter": {'code': '1f48c', 'aliases': []}, + "love_you_gesture": {'code': '1f91f', 'aliases': ['ily']}, + "low_battery": {'code': '1faab', 'aliases': ['electronic', 'low_energy']}, + "low_brightness": {'code': '1f505', 'aliases': ['dim']}, + "lower_left": {'code': '2199', 'aliases': ['south_west']}, + "lower_right": {'code': '2198', 'aliases': ['south_east']}, + "lucky": {'code': '1f340', 'aliases': ['four_leaf_clover']}, + "luggage": {'code': '1f9f3', 'aliases': ['packing', 'travel']}, + "lungs": {'code': '1fac1', 'aliases': ['breath', 'exhalation', 'inhalation', 'respiration']}, + "lying": {'code': '1f925', 'aliases': []}, + "mage": {'code': '1f9d9', 'aliases': []}, + "magic_wand": {'code': '1fa84', 'aliases': ['magic']}, + "magnet": {'code': '1f9f2', 'aliases': ['attraction', 'horseshoe']}, + "magnifying_glass_tilted_right": {'code': '1f50e', 'aliases': ['magnifying']}, + "mahjong": {'code': '1f004', 'aliases': []}, + "mail_dropoff": {'code': '1f4ee', 'aliases': []}, + "mail_received": {'code': '1f4e8', 'aliases': []}, + "mail_sent": {'code': '1f4e9', 'aliases': ['sealed']}, + "mailbox": {'code': '1f4eb', 'aliases': []}, + "male_sign": {'code': '2642', 'aliases': []}, + "mammoth": {'code': '1f9a3', 'aliases': ['tusk', 'woolly']}, + "man": {'code': '1f468', 'aliases': []}, + "man_and_woman_holding_hands": {'code': '1f46b', 'aliases': ['man_and_woman_couple']}, + "man_artist": {'code': '1f468-200d-1f3a8', 'aliases': []}, + "man_astronaut": {'code': '1f468-200d-1f680', 'aliases': []}, + "man_bald": {'code': '1f468-200d-1f9b2', 'aliases': []}, + "man_beard": {'code': '1f9d4-200d-2642', 'aliases': []}, + "man_biking": {'code': '1f6b4-200d-2642', 'aliases': []}, + "man_blond_hair": {'code': '1f471-200d-2642', 'aliases': ['blond_haired_man']}, + "man_bouncing_ball": {'code': '26f9-fe0f-200d-2642-fe0f', 'aliases': []}, + "man_bowing": {'code': '1f647-200d-2642', 'aliases': []}, + "man_cartwheeling": {'code': '1f938-200d-2642', 'aliases': []}, + "man_climbing": {'code': '1f9d7-200d-2642', 'aliases': []}, + "man_construction_worker": {'code': '1f477-200d-2642', 'aliases': []}, + "man_cook": {'code': '1f468-200d-1f373', 'aliases': []}, + "man_curly_hair": {'code': '1f468-200d-1f9b1', 'aliases': []}, + "man_detective": {'code': '1f575-fe0f-200d-2642-fe0f', 'aliases': []}, + "man_elf": {'code': '1f9dd-200d-2642', 'aliases': []}, + "man_facepalming": {'code': '1f926-200d-2642', 'aliases': []}, + "man_factory_worker": {'code': '1f468-200d-1f3ed', 'aliases': []}, + "man_fairy": {'code': '1f9da-200d-2642', 'aliases': []}, + "man_farmer": {'code': '1f468-200d-1f33e', 'aliases': []}, + "man_feeding_baby": {'code': '1f468-200d-1f37c', 'aliases': []}, + "man_firefighter": {'code': '1f468-200d-1f692', 'aliases': []}, + "man_frowning": {'code': '1f64d-200d-2642', 'aliases': []}, + "man_genie": {'code': '1f9de-200d-2642', 'aliases': []}, + "man_gesturing_no": {'code': '1f645-200d-2642', 'aliases': []}, + "man_gesturing_ok": {'code': '1f646-200d-2642', 'aliases': []}, + "man_getting_haircut": {'code': '1f487-200d-2642', 'aliases': []}, + "man_getting_massage": {'code': '1f486-200d-2642', 'aliases': []}, + "man_golfing": {'code': '1f3cc-fe0f-200d-2642-fe0f', 'aliases': []}, + "man_guard": {'code': '1f482-200d-2642', 'aliases': []}, + "man_health_worker": {'code': '1f468-200d-2695', 'aliases': []}, + "man_in_lotus_position": {'code': '1f9d8-200d-2642', 'aliases': []}, + "man_in_manual_wheelchair": {'code': '1f468-200d-1f9bd', 'aliases': []}, + "man_in_motorized_wheelchair": {'code': '1f468-200d-1f9bc', 'aliases': []}, + "man_in_steamy_room": {'code': '1f9d6-200d-2642', 'aliases': []}, + "man_in_tuxedo": {'code': '1f935-200d-2642', 'aliases': []}, + "man_judge": {'code': '1f468-200d-2696', 'aliases': []}, + "man_juggling": {'code': '1f939-200d-2642', 'aliases': []}, + "man_kneeling": {'code': '1f9ce-200d-2642', 'aliases': []}, + "man_lifting_weights": {'code': '1f3cb-fe0f-200d-2642-fe0f', 'aliases': []}, + "man_mage": {'code': '1f9d9-200d-2642', 'aliases': []}, + "man_mechanic": {'code': '1f468-200d-1f527', 'aliases': []}, + "man_mountain_biking": {'code': '1f6b5-200d-2642', 'aliases': []}, + "man_office_worker": {'code': '1f468-200d-1f4bc', 'aliases': []}, + "man_pilot": {'code': '1f468-200d-2708', 'aliases': []}, + "man_playing_handball": {'code': '1f93e-200d-2642', 'aliases': []}, + "man_playing_water_polo": {'code': '1f93d-200d-2642', 'aliases': []}, + "man_police_officer": {'code': '1f46e-200d-2642', 'aliases': []}, + "man_pouting": {'code': '1f64e-200d-2642', 'aliases': []}, + "man_raising_hand": {'code': '1f64b-200d-2642', 'aliases': []}, + "man_red_hair": {'code': '1f468-200d-1f9b0', 'aliases': []}, + "man_rowing_boat": {'code': '1f6a3-200d-2642', 'aliases': []}, + "man_running": {'code': '1f3c3-200d-2642', 'aliases': []}, + "man_scientist": {'code': '1f468-200d-1f52c', 'aliases': []}, + "man_shrugging": {'code': '1f937-200d-2642', 'aliases': []}, + "man_singer": {'code': '1f468-200d-1f3a4', 'aliases': []}, + "man_standing": {'code': '1f9cd-200d-2642', 'aliases': []}, + "man_student": {'code': '1f468-200d-1f393', 'aliases': []}, + "man_superhero": {'code': '1f9b8-200d-2642', 'aliases': []}, + "man_supervillain": {'code': '1f9b9-200d-2642', 'aliases': []}, + "man_surfing": {'code': '1f3c4-200d-2642', 'aliases': []}, + "man_swimming": {'code': '1f3ca-200d-2642', 'aliases': []}, + "man_teacher": {'code': '1f468-200d-1f3eb', 'aliases': []}, + "man_technologist": {'code': '1f468-200d-1f4bb', 'aliases': []}, + "man_tipping_hand": {'code': '1f481-200d-2642', 'aliases': []}, + "man_vampire": {'code': '1f9db-200d-2642', 'aliases': []}, + "man_walking": {'code': '1f6b6-200d-2642', 'aliases': []}, + "man_wearing_turban": {'code': '1f473-200d-2642', 'aliases': []}, + "man_white_hair": {'code': '1f468-200d-1f9b3', 'aliases': []}, + "man_with_veil": {'code': '1f470-200d-2642', 'aliases': []}, + "man_with_white_cane": {'code': '1f468-200d-1f9af', 'aliases': []}, + "man_zombie": {'code': '1f9df-200d-2642', 'aliases': []}, + "mango": {'code': '1f96d', 'aliases': ['fruit']}, + "mantelpiece_clock": {'code': '1f570', 'aliases': []}, + "manual_wheelchair": {'code': '1f9bd', 'aliases': []}, + "map": {'code': '1f5fa', 'aliases': ['world_map', 'road_trip']}, + "maple_leaf": {'code': '1f341', 'aliases': []}, + "mask": {'code': '1f637', 'aliases': []}, + "massage": {'code': '1f486', 'aliases': []}, + "mate": {'code': '1f9c9', 'aliases': []}, + "meat": {'code': '1f356', 'aliases': []}, + "mechanic": {'code': '1f9d1-200d-1f527', 'aliases': []}, + "mechanical_arm": {'code': '1f9be', 'aliases': []}, + "mechanical_leg": {'code': '1f9bf', 'aliases': []}, + "medal": {'code': '1f3c5', 'aliases': []}, + "medical_symbol": {'code': '2695', 'aliases': ['aesculapius', 'staff']}, + "medicine": {'code': '1f48a', 'aliases': ['pill']}, + "megaphone": {'code': '1f4e3', 'aliases': ['shout']}, + "melon": {'code': '1f348', 'aliases': []}, + "melting_face": {'code': '1fae0', 'aliases': ['dissolve', 'liquid', 'melt']}, + "memo": {'code': '1f4dd', 'aliases': ['note']}, + "men_with_bunny_ears": {'code': '1f46f-200d-2642', 'aliases': []}, + "men_wrestling": {'code': '1f93c-200d-2642', 'aliases': []}, + "mending_heart": {'code': '2764-200d-1fa79', 'aliases': ['healthier', 'improving', 'mending', 'recovering', 'recuperating', 'well']}, + "menorah": {'code': '1f54e', 'aliases': []}, + "mens": {'code': '1f6b9', 'aliases': []}, + "mermaid": {'code': '1f9dc-200d-2640', 'aliases': []}, + "merman": {'code': '1f9dc-200d-2642', 'aliases': ['triton']}, + "merperson": {'code': '1f9dc', 'aliases': []}, + "metro": {'code': '24c2', 'aliases': ['m']}, + "microbe": {'code': '1f9a0', 'aliases': ['amoeba']}, + "microphone": {'code': '1f3a4', 'aliases': ['mike', 'mic']}, + "middle_finger": {'code': '1f595', 'aliases': []}, + "military_helmet": {'code': '1fa96', 'aliases': ['army', 'military', 'soldier', 'warrior']}, + "military_medal": {'code': '1f396', 'aliases': []}, + "milk": {'code': '1f95b', 'aliases': ['glass_of_milk']}, + "milky_way": {'code': '1f30c', 'aliases': ['night_sky']}, + "mine": {'code': '26cf', 'aliases': ['pick']}, + "minibus": {'code': '1f690', 'aliases': []}, + "minus": {'code': '2796', 'aliases': ['subtract']}, + "mirror": {'code': '1fa9e', 'aliases': ['reflection', 'reflector', 'speculum']}, + "mirror_ball": {'code': '1faa9', 'aliases': ['glitter']}, + "mobile_phone": {'code': '1f4f1', 'aliases': ['smartphone', 'iphone', 'android']}, + "money": {'code': '1f4b0', 'aliases': []}, + "money_face": {'code': '1f911', 'aliases': ['kaching']}, + "monkey": {'code': '1f412', 'aliases': []}, + "monkey_face": {'code': '1f435', 'aliases': []}, + "monorail": {'code': '1f69d', 'aliases': ['elevated_train']}, + "moon": {'code': '1f319', 'aliases': []}, + "moon_cake": {'code': '1f96e', 'aliases': ['autumn', 'festival', 'yuèbǐng', 'yuebing']}, + "moon_ceremony": {'code': '1f391', 'aliases': []}, + "moon_face": {'code': '1f31d', 'aliases': []}, + "mosque": {'code': '1f54c', 'aliases': []}, + "mosquito": {'code': '1f99f', 'aliases': ['malaria']}, + "mostly_sunny": {'code': '1f324', 'aliases': []}, + "mother_christmas": {'code': '1f936', 'aliases': ['mrs_claus']}, + "motor_boat": {'code': '1f6e5', 'aliases': []}, + "motorcycle": {'code': '1f3cd', 'aliases': []}, + "motorized_wheelchair": {'code': '1f9bc', 'aliases': []}, + "mount_fuji": {'code': '1f5fb', 'aliases': []}, + "mountain": {'code': '26f0', 'aliases': []}, + "mountain_biker": {'code': '1f6b5', 'aliases': []}, + "mountain_railway": {'code': '1f69e', 'aliases': []}, + "mountain_sunrise": {'code': '1f304', 'aliases': []}, + "mouse": {'code': '1f401', 'aliases': []}, + "mouse_trap": {'code': '1faa4', 'aliases': ['bait', 'mousetrap', 'snare', 'trap']}, + "movie_camera": {'code': '1f3a5', 'aliases': []}, + "moving_truck": {'code': '1f69a', 'aliases': []}, + "multiplication": {'code': '2716', 'aliases': ['multiply']}, + "muscle": {'code': '1f4aa', 'aliases': []}, + "mushroom": {'code': '1f344', 'aliases': []}, + "music": {'code': '1f3b5', 'aliases': []}, + "musical_notes": {'code': '1f3b6', 'aliases': []}, + "musical_score": {'code': '1f3bc', 'aliases': []}, + "mute": {'code': '1f507', 'aliases': ['no_sound']}, + "mute_notifications": {'code': '1f515', 'aliases': []}, + "mx_claus": {'code': '1f9d1-200d-1f384', 'aliases': ['claus_christmas']}, + "nail_polish": {'code': '1f485', 'aliases': ['nail_care']}, + "name_badge": {'code': '1f4db', 'aliases': []}, + "naruto": {'code': '1f365', 'aliases': []}, + "national_park": {'code': '1f3de', 'aliases': []}, + "nauseated": {'code': '1f922', 'aliases': ['queasy']}, + "nazar_amulet": {'code': '1f9ff', 'aliases': ['bead', 'charm', 'evil_eye', 'nazar', 'talisman']}, + "nerd": {'code': '1f913', 'aliases': ['geek']}, + "nest_with_eggs": {'code': '1faba', 'aliases': []}, + "nesting_dolls": {'code': '1fa86', 'aliases': ['doll', 'russia']}, + "neutral": {'code': '1f610', 'aliases': []}, + "new": {'code': '1f195', 'aliases': []}, + "new_baby": {'code': '1f425', 'aliases': []}, + "new_moon": {'code': '1f311', 'aliases': []}, + "new_moon_face": {'code': '1f31a', 'aliases': []}, + "newspaper": {'code': '1f5de', 'aliases': ['swat']}, + "next_track": {'code': '23ed', 'aliases': ['skip_forward']}, + "ng": {'code': '1f196', 'aliases': []}, + "night": {'code': '1f303', 'aliases': []}, + "nine": {'code': '0039-20e3', 'aliases': []}, + "ninja": {'code': '1f977', 'aliases': ['fighter', 'hidden', 'stealth']}, + "no_bicycles": {'code': '1f6b3', 'aliases': []}, + "no_entry": {'code': '26d4', 'aliases': ['wrong_way']}, + "no_pedestrians": {'code': '1f6b7', 'aliases': []}, + "no_phones": {'code': '1f4f5', 'aliases': []}, + "no_signal": {'code': '1f645', 'aliases': ['nope']}, + "no_smoking": {'code': '1f6ad', 'aliases': []}, + "non-potable_water": {'code': '1f6b1', 'aliases': []}, + "nose": {'code': '1f443', 'aliases': []}, + "notebook": {'code': '1f4d3', 'aliases': ['composition_book']}, + "notifications": {'code': '1f514', 'aliases': ['bell']}, + "nut_and_bolt": {'code': '1f529', 'aliases': ['screw']}, + "o": {'code': '1f17e', 'aliases': []}, + "ocean": {'code': '1f30a', 'aliases': []}, + "octopus": {'code': '1f419', 'aliases': []}, + "oden": {'code': '1f362', 'aliases': []}, + "office": {'code': '1f3e2', 'aliases': []}, + "office_supplies": {'code': '1f587', 'aliases': ['paperclip_chain', 'linked']}, + "office_worker": {'code': '1f9d1-200d-1f4bc', 'aliases': []}, + "ogre": {'code': '1f479', 'aliases': []}, + "oh_no": {'code': '1f615', 'aliases': ['half_frown', 'concerned', 'confused']}, + "oil_drum": {'code': '1f6e2', 'aliases': ['commodities']}, + "ok": {'code': '1f44c', 'aliases': ['got_it']}, + "ok_signal": {'code': '1f646', 'aliases': []}, + "older_man": {'code': '1f474', 'aliases': ['elderly_man']}, + "older_person": {'code': '1f9d3', 'aliases': ['old']}, + "older_woman": {'code': '1f475', 'aliases': ['elderly_woman']}, + "olive": {'code': '1fad2', 'aliases': []}, + "om": {'code': '1f549', 'aliases': ['hinduism']}, + "on": {'code': '1f51b', 'aliases': []}, + "oncoming_bus": {'code': '1f68d', 'aliases': []}, + "oncoming_car": {'code': '1f698', 'aliases': ['oncoming_automobile']}, + "oncoming_police_car": {'code': '1f694', 'aliases': []}, + "oncoming_taxi": {'code': '1f696', 'aliases': []}, + "oncoming_train": {'code': '1f686', 'aliases': []}, + "oncoming_tram": {'code': '1f68a', 'aliases': ['oncoming_streetcar', 'oncoming_trolley']}, + "one": {'code': '0031-20e3', 'aliases': []}, + "one_piece_swimsuit": {'code': '1fa71', 'aliases': []}, + "onigiri": {'code': '1f359', 'aliases': []}, + "onion": {'code': '1f9c5', 'aliases': []}, + "open_hands": {'code': '1f450', 'aliases': []}, + "open_mouth": {'code': '1f62e', 'aliases': ['surprise']}, + "ophiuchus": {'code': '26ce', 'aliases': []}, + "orange": {'code': '1f34a', 'aliases': ['tangerine', 'mandarin']}, + "orange_book": {'code': '1f4d9', 'aliases': []}, + "orange_circle": {'code': '1f7e0', 'aliases': []}, + "orange_heart": {'code': '1f9e1', 'aliases': []}, + "orange_square": {'code': '1f7e7', 'aliases': []}, + "orangutan": {'code': '1f9a7', 'aliases': ['ape']}, + "organize": {'code': '1f4c1', 'aliases': ['file_folder']}, + "orthodox_cross": {'code': '2626', 'aliases': []}, + "otter": {'code': '1f9a6', 'aliases': ['playful']}, + "outbox": {'code': '1f4e4', 'aliases': []}, + "owl": {'code': '1f989', 'aliases': []}, + "ox": {'code': '1f402', 'aliases': ['bull']}, + "oyster": {'code': '1f9aa', 'aliases': []}, + "package": {'code': '1f4e6', 'aliases': []}, + "paella": {'code': '1f958', 'aliases': []}, + "page_with_curl": {'code': '1f4c3', 'aliases': ['curl']}, + "pager": {'code': '1f4df', 'aliases': []}, + "paintbrush": {'code': '1f58c', 'aliases': []}, + "palm_down_hand": {'code': '1faf3', 'aliases': ['dismiss', 'shoo']}, + "palm_tree": {'code': '1f334', 'aliases': []}, + "palm_up_hand": {'code': '1faf4', 'aliases': ['beckon', 'come', 'offer']}, + "palms_up_together": {'code': '1f932', 'aliases': ['prayer']}, + "pancakes": {'code': '1f95e', 'aliases': ['breakfast']}, + "panda": {'code': '1f43c', 'aliases': []}, + "paperclip": {'code': '1f4ce', 'aliases': ['attachment']}, + "parachute": {'code': '1fa82', 'aliases': ['hang_glide', 'parasail', 'skydive']}, + "parking": {'code': '1f17f', 'aliases': ['p']}, + "parrot": {'code': '1f99c', 'aliases': ['talk']}, + "part_alternation": {'code': '303d', 'aliases': []}, + "partly_sunny": {'code': '26c5', 'aliases': ['partly_cloudy']}, + "partying_face": {'code': '1f973', 'aliases': []}, + "pass": {'code': '1f3ab', 'aliases': []}, + "passenger_ship": {'code': '1f6f3', 'aliases': ['yacht', 'cruise']}, + "passport_control": {'code': '1f6c2', 'aliases': ['immigration']}, + "pause": {'code': '23f8', 'aliases': []}, + "paw_prints": {'code': '1f43e', 'aliases': ['paws']}, + "peace": {'code': '262e', 'aliases': []}, + "peace_sign": {'code': '270c', 'aliases': ['victory']}, + "peach": {'code': '1f351', 'aliases': []}, + "peacock": {'code': '1f99a', 'aliases': ['ostentatious', 'peahen']}, + "peanuts": {'code': '1f95c', 'aliases': []}, + "pear": {'code': '1f350', 'aliases': []}, + "pen": {'code': '1f58a', 'aliases': ['ballpoint_pen']}, + "pencil": {'code': '270f', 'aliases': []}, + "penguin": {'code': '1f427', 'aliases': []}, + "pensive": {'code': '1f614', 'aliases': ['tired']}, + "people_holding_hands": {'code': '1f9d1-200d-1f91d-200d-1f9d1', 'aliases': ['hold', 'holding_hands']}, + "people_hugging": {'code': '1fac2', 'aliases': ['goodbye', 'thanks']}, + "performing_arts": {'code': '1f3ad', 'aliases': ['drama', 'theater']}, + "persevere": {'code': '1f623', 'aliases': ['helpless']}, + "person": {'code': '1f9d1', 'aliases': []}, + "person_bald": {'code': '1f9d1-200d-1f9b2', 'aliases': []}, + "person_beard": {'code': '1f9d4', 'aliases': []}, + "person_blond_hair": {'code': '1f471', 'aliases': ['blond_haired_person']}, + "person_climbing": {'code': '1f9d7', 'aliases': []}, + "person_curly_hair": {'code': '1f9d1-200d-1f9b1', 'aliases': []}, + "person_feeding_baby": {'code': '1f9d1-200d-1f37c', 'aliases': []}, + "person_frowning": {'code': '1f64d', 'aliases': []}, + "person_in_lotus_position": {'code': '1f9d8', 'aliases': []}, + "person_in_manual_wheelchair": {'code': '1f9d1-200d-1f9bd', 'aliases': []}, + "person_in_motorized_wheelchair": {'code': '1f9d1-200d-1f9bc', 'aliases': []}, + "person_in_steamy_room": {'code': '1f9d6', 'aliases': []}, + "person_kneeling": {'code': '1f9ce', 'aliases': ['kneel']}, + "person_pouting": {'code': '1f64e', 'aliases': []}, + "person_red_hair": {'code': '1f9d1-200d-1f9b0', 'aliases': []}, + "person_standing": {'code': '1f9cd', 'aliases': ['stand']}, + "person_white_hair": {'code': '1f9d1-200d-1f9b3', 'aliases': []}, + "person_with_crown": {'code': '1fac5', 'aliases': ['monarch', 'noble', 'regal', 'royalty']}, + "person_with_white_cane": {'code': '1f9d1-200d-1f9af', 'aliases': []}, + "petri_dish": {'code': '1f9eb', 'aliases': ['biology', 'culture']}, + "phone": {'code': '260e', 'aliases': ['telephone']}, + "phone_off": {'code': '1f4f4', 'aliases': []}, + "piano": {'code': '1f3b9', 'aliases': ['musical_keyboard']}, + "pickup_truck": {'code': '1f6fb', 'aliases': ['pick_up', 'pickup']}, + "picture": {'code': '1f5bc', 'aliases': ['framed_picture']}, + "pie": {'code': '1f967', 'aliases': ['filling', 'pastry']}, + "pig": {'code': '1f416', 'aliases': ['oink']}, + "pig_nose": {'code': '1f43d', 'aliases': []}, + "piglet": {'code': '1f437', 'aliases': []}, + "pilot": {'code': '1f9d1-200d-2708', 'aliases': []}, + "pin": {'code': '1f4cd', 'aliases': ['sewing_pin']}, + "pinched_fingers": {'code': '1f90c', 'aliases': ['fingers', 'hand_gesture', 'interrogation', 'pinched', 'sarcastic']}, + "pinching_hand": {'code': '1f90f', 'aliases': ['small_amount']}, + "pineapple": {'code': '1f34d', 'aliases': []}, + "ping_pong": {'code': '1f3d3', 'aliases': ['table_tennis']}, + "pirate_flag": {'code': '1f3f4-200d-2620', 'aliases': ['jolly_roger', 'plunder']}, + "pisces": {'code': '2653', 'aliases': []}, + "pizza": {'code': '1f355', 'aliases': []}, + "piñata": {'code': '1fa85', 'aliases': ['pinata']}, + "placard": {'code': '1faa7', 'aliases': ['demonstration', 'picket', 'protest', 'sign']}, + "place_holder": {'code': '1f4d1', 'aliases': []}, + "place_of_worship": {'code': '1f6d0', 'aliases': []}, + "play": {'code': '25b6', 'aliases': []}, + "play_pause": {'code': '23ef', 'aliases': []}, + "play_reverse": {'code': '25c0', 'aliases': []}, + "playground_slide": {'code': '1f6dd', 'aliases': ['amusement_park']}, + "playing_cards": {'code': '1f3b4', 'aliases': []}, + "pleading_face": {'code': '1f97a', 'aliases': ['begging', 'mercy', 'puppy_eyes']}, + "plunger": {'code': '1faa0', 'aliases': ['force_cup', 'suction']}, + "plus": {'code': '2795', 'aliases': ['add']}, + "point_down": {'code': '1f447', 'aliases': []}, + "point_left": {'code': '1f448', 'aliases': []}, + "point_right": {'code': '1f449', 'aliases': []}, + "point_up": {'code': '1f446', 'aliases': ['this']}, + "polar_bear": {'code': '1f43b-200d-2744', 'aliases': ['arctic']}, + "police": {'code': '1f46e', 'aliases': ['cop']}, + "police_car": {'code': '1f693', 'aliases': []}, + "pony": {'code': '1f434', 'aliases': []}, + "poodle": {'code': '1f429', 'aliases': []}, + "poop": {'code': '1f4a9', 'aliases': ['pile_of_poo']}, + "popcorn": {'code': '1f37f', 'aliases': []}, + "post_office": {'code': '1f3e4', 'aliases': []}, + "potable_water": {'code': '1f6b0', 'aliases': ['tap_water', 'drinking_water']}, + "potato": {'code': '1f954', 'aliases': []}, + "potted_plant": {'code': '1fab4', 'aliases': ['boring', 'grow', 'nurturing', 'useless']}, + "pouch": {'code': '1f45d', 'aliases': []}, + "pound_notes": {'code': '1f4b7', 'aliases': []}, + "pouring_liquid": {'code': '1fad7', 'aliases': ['spill']}, + "pray": {'code': '1f64f', 'aliases': ['welcome', 'thank_you', 'namaste']}, + "prayer_beads": {'code': '1f4ff', 'aliases': []}, + "pregnant": {'code': '1f930', 'aliases': ['expecting']}, + "pregnant_man": {'code': '1fac3', 'aliases': []}, + "pregnant_person": {'code': '1fac4', 'aliases': []}, + "pretzel": {'code': '1f968', 'aliases': ['twisted']}, + "previous_track": {'code': '23ee', 'aliases': ['skip_back']}, + "prince": {'code': '1f934', 'aliases': []}, + "princess": {'code': '1f478', 'aliases': []}, + "printer": {'code': '1f5a8', 'aliases': []}, + "privacy": {'code': '1f50f', 'aliases': ['key_signing', 'digital_security', 'protected']}, + "prohibited": {'code': '1f6ab', 'aliases': ['not_allowed']}, + "projector": {'code': '1f4fd', 'aliases': ['movie']}, + "puppy": {'code': '1f436', 'aliases': []}, + "purple_circle": {'code': '1f7e3', 'aliases': []}, + "purple_heart": {'code': '1f49c', 'aliases': ['bravery']}, + "purple_square": {'code': '1f7ea', 'aliases': []}, + "purse": {'code': '1f45b', 'aliases': []}, + "push_pin": {'code': '1f4cc', 'aliases': ['thumb_tack']}, + "put_litter_in_its_place": {'code': '1f6ae', 'aliases': []}, + "puzzle_piece": {'code': '1f9e9', 'aliases': ['interlocking', 'jigsaw', 'piece', 'puzzle']}, + "question": {'code': '2753', 'aliases': []}, + "rabbit": {'code': '1f407', 'aliases': []}, + "raccoon": {'code': '1f99d', 'aliases': ['curious', 'sly']}, + "racecar": {'code': '1f3ce', 'aliases': []}, + "radio": {'code': '1f4fb', 'aliases': []}, + "radio_button": {'code': '1f518', 'aliases': []}, + "radioactive": {'code': '2622', 'aliases': ['nuclear']}, + "rage": {'code': '1f621', 'aliases': ['mad', 'grumpy', 'very_angry']}, + "railway_car": {'code': '1f683', 'aliases': ['train_car']}, + "railway_track": {'code': '1f6e4', 'aliases': ['train_tracks']}, + "rainbow": {'code': '1f308', 'aliases': ['pride', 'lgbtq']}, + "rainbow_flag": {'code': '1f3f3-200d-1f308', 'aliases': []}, + "rainy": {'code': '1f327', 'aliases': ['soaked', 'drenched']}, + "raised_hands": {'code': '1f64c', 'aliases': ['praise']}, + "raising_hand": {'code': '1f64b', 'aliases': ['pick_me']}, + "ram": {'code': '1f40f', 'aliases': []}, + "ramen": {'code': '1f35c', 'aliases': ['noodles']}, + "rat": {'code': '1f400', 'aliases': []}, + "razor": {'code': '1fa92', 'aliases': ['sharp', 'shave']}, + "receipt": {'code': '1f9fe', 'aliases': ['accounting', 'bookkeeping', 'evidence', 'proof']}, + "record": {'code': '23fa', 'aliases': []}, + "recreational_vehicle": {'code': '1f699', 'aliases': ['jeep']}, + "recycle": {'code': '267b', 'aliases': []}, + "red_book": {'code': '1f4d5', 'aliases': ['closed_book']}, + "red_circle": {'code': '1f534', 'aliases': []}, + "red_envelope": {'code': '1f9e7', 'aliases': ['good_luck', 'hóngbāo', 'lai_see', 'hongbao']}, + "red_square": {'code': '1f7e5', 'aliases': ['red']}, + "red_triangle_down": {'code': '1f53b', 'aliases': []}, + "red_triangle_up": {'code': '1f53a', 'aliases': []}, + "registered": {'code': '00ae', 'aliases': ['r']}, + "relieved": {'code': '1f60c', 'aliases': []}, + "reminder_ribbon": {'code': '1f397', 'aliases': []}, + "repeat": {'code': '1f501', 'aliases': []}, + "repeat_one": {'code': '1f502', 'aliases': []}, + "reply": {'code': '21a9', 'aliases': ['left_hook']}, + "restroom": {'code': '1f6bb', 'aliases': []}, + "revolving_hearts": {'code': '1f49e', 'aliases': []}, + "rewind": {'code': '23ea', 'aliases': ['fast_reverse']}, + "rhinoceros": {'code': '1f98f', 'aliases': []}, + "ribbon": {'code': '1f380', 'aliases': ['decoration']}, + "rice": {'code': '1f35a', 'aliases': []}, + "right": {'code': '27a1', 'aliases': ['east']}, + "right_fist": {'code': '1f91c', 'aliases': []}, + "rightwards_hand": {'code': '1faf1', 'aliases': ['rightward']}, + "ring": {'code': '1f48d', 'aliases': []}, + "ring_buoy": {'code': '1f6df', 'aliases': ['float', 'life_preserver', 'life_saver', 'rescue']}, + "ringed_planet": {'code': '1fa90', 'aliases': ['saturn', 'saturnine']}, + "road": {'code': '1f6e3', 'aliases': ['motorway']}, + "robot": {'code': '1f916', 'aliases': []}, + "rock": {'code': '1faa8', 'aliases': ['boulder', 'heavy', 'solid', 'stone']}, + "rock_carving": {'code': '1f5ff', 'aliases': ['moyai']}, + "rock_on": {'code': '1f918', 'aliases': ['sign_of_the_horns']}, + "rocket": {'code': '1f680', 'aliases': []}, + "roll_of_paper": {'code': '1f9fb', 'aliases': ['paper_towels', 'toilet_paper']}, + "roller_coaster": {'code': '1f3a2', 'aliases': []}, + "roller_skate": {'code': '1f6fc', 'aliases': ['roller', 'skate']}, + "rolling_eyes": {'code': '1f644', 'aliases': []}, + "rolling_on_the_floor_laughing": {'code': '1f923', 'aliases': ['rofl']}, + "rolodex": {'code': '1f4c7', 'aliases': ['card_index']}, + "rooster": {'code': '1f413', 'aliases': ['alarm', 'cock-a-doodle-doo']}, + "rose": {'code': '1f339', 'aliases': []}, + "rosette": {'code': '1f3f5', 'aliases': []}, + "rowboat": {'code': '1f6a3', 'aliases': ['crew', 'sculling', 'rowing']}, + "rugby": {'code': '1f3c9', 'aliases': []}, + "ruler": {'code': '1f4cf', 'aliases': ['straightedge']}, + "running": {'code': '1f3c3', 'aliases': ['runner']}, + "running_shirt": {'code': '1f3bd', 'aliases': []}, + "sad": {'code': '2639', 'aliases': ['big_frown']}, + "safety_pin": {'code': '1f9f7', 'aliases': ['diaper', 'punk_rock']}, + "safety_vest": {'code': '1f9ba', 'aliases': ['emergency', 'vest']}, + "sagittarius": {'code': '2650', 'aliases': []}, + "sake": {'code': '1f376', 'aliases': []}, + "salad": {'code': '1f957', 'aliases': []}, + "salt": {'code': '1f9c2', 'aliases': ['shaker']}, + "saluting_face": {'code': '1fae1', 'aliases': ['salute', 'troops', 'yes']}, + "sandal": {'code': '1f461', 'aliases': ['flip_flops']}, + "sandwich": {'code': '1f96a', 'aliases': []}, + "santa": {'code': '1f385', 'aliases': []}, + "sari": {'code': '1f97b', 'aliases': []}, + "satellite": {'code': '1f6f0', 'aliases': []}, + "satellite_antenna": {'code': '1f4e1', 'aliases': []}, + "sauropod": {'code': '1f995', 'aliases': ['brachiosaurus', 'brontosaurus', 'diplodocus']}, + "saxophone": {'code': '1f3b7', 'aliases': []}, + "scarf": {'code': '1f9e3', 'aliases': ['neck']}, + "school": {'code': '1f3eb', 'aliases': []}, + "science": {'code': '1f52c', 'aliases': ['microscope']}, + "scientist": {'code': '1f9d1-200d-1f52c', 'aliases': []}, + "scissors": {'code': '2702', 'aliases': []}, + "scooter": {'code': '1f6f5', 'aliases': ['motor_bike']}, + "scorpion": {'code': '1f982', 'aliases': []}, + "scorpius": {'code': '264f', 'aliases': []}, + "scream": {'code': '1f631', 'aliases': []}, + "scream_cat": {'code': '1f640', 'aliases': ['weary_cat']}, + "screwdriver": {'code': '1fa9b', 'aliases': []}, + "scroll": {'code': '1f4dc', 'aliases': []}, + "seal": {'code': '1f9ad', 'aliases': ['sea_lion']}, + "search": {'code': '1f50d', 'aliases': ['find', 'magnifying_glass']}, + "seat": {'code': '1f4ba', 'aliases': []}, + "second_place": {'code': '1f948', 'aliases': ['silver']}, + "secret": {'code': '1f5dd', 'aliases': ['dungeon', 'old_key', 'encrypted', 'clue', 'hint']}, + "secure": {'code': '1f510', 'aliases': ['lock_with_key', 'safe', 'commitment', 'loyalty']}, + "see_no_evil": {'code': '1f648', 'aliases': []}, + "seedling": {'code': '1f331', 'aliases': ['sprout']}, + "seeing_stars": {'code': '1f4ab', 'aliases': []}, + "selfie": {'code': '1f933', 'aliases': []}, + "senbei": {'code': '1f358', 'aliases': ['rice_cracker']}, + "service_dog": {'code': '1f415-200d-1f9ba', 'aliases': ['assistance', 'service']}, + "seven": {'code': '0037-20e3', 'aliases': []}, + "sewing_needle": {'code': '1faa1', 'aliases': ['embroidery', 'stitches', 'sutures', 'tailoring']}, + "shamrock": {'code': '2618', 'aliases': ['clover']}, + "shark": {'code': '1f988', 'aliases': []}, + "shaved_ice": {'code': '1f367', 'aliases': []}, + "sheep": {'code': '1f411', 'aliases': ['baa']}, + "shell": {'code': '1f41a', 'aliases': ['seashell', 'conch', 'spiral_shell']}, + "shield": {'code': '1f6e1', 'aliases': []}, + "shinto_shrine": {'code': '26e9', 'aliases': []}, + "ship": {'code': '1f6a2', 'aliases': []}, + "shiro": {'code': '1f3ef', 'aliases': []}, + "shirt": {'code': '1f455', 'aliases': ['tshirt']}, + "shoe": {'code': '1f45e', 'aliases': []}, + "shooting_star": {'code': '1f320', 'aliases': ['wish']}, + "shopping_bags": {'code': '1f6cd', 'aliases': []}, + "shopping_cart": {'code': '1f6d2', 'aliases': ['shopping_trolley']}, + "shorts": {'code': '1fa73', 'aliases': ['pants']}, + "shower": {'code': '1f6bf', 'aliases': []}, + "shrimp": {'code': '1f990', 'aliases': []}, + "shrug": {'code': '1f937', 'aliases': []}, + "shuffle": {'code': '1f500', 'aliases': []}, + "shushing_face": {'code': '1f92b', 'aliases': ['shush']}, + "sick": {'code': '1f912', 'aliases': ['flu', 'face_with_thermometer', 'ill', 'fever']}, + "silence": {'code': '1f910', 'aliases': ['quiet', 'hush', 'zip_it', 'lips_are_sealed']}, + "silhouette": {'code': '1f464', 'aliases': ['shadow']}, + "silhouettes": {'code': '1f465', 'aliases': ['shadows']}, + "singer": {'code': '1f9d1-200d-1f3a4', 'aliases': []}, + "siren": {'code': '1f6a8', 'aliases': ['rotating_light', 'alert']}, + "six": {'code': '0036-20e3', 'aliases': []}, + "skateboard": {'code': '1f6f9', 'aliases': ['board']}, + "ski": {'code': '1f3bf', 'aliases': []}, + "skier": {'code': '26f7', 'aliases': []}, + "skull": {'code': '1f480', 'aliases': []}, + "skull_and_crossbones": {'code': '2620', 'aliases': ['pirate', 'death', 'hazard', 'toxic', 'poison']}, + "skunk": {'code': '1f9a8', 'aliases': ['stink']}, + "sled": {'code': '1f6f7', 'aliases': ['sledge', 'sleigh']}, + "sleeping": {'code': '1f634', 'aliases': []}, + "sleepy": {'code': '1f62a', 'aliases': []}, + "slot_machine": {'code': '1f3b0', 'aliases': []}, + "sloth": {'code': '1f9a5', 'aliases': ['lazy', 'slow']}, + "small_airplane": {'code': '1f6e9', 'aliases': []}, + "small_blue_diamond": {'code': '1f539', 'aliases': []}, + "small_glass": {'code': '1f943', 'aliases': []}, + "small_orange_diamond": {'code': '1f538', 'aliases': []}, + "smile": {'code': '1f642', 'aliases': []}, + "smile_cat": {'code': '1f638', 'aliases': []}, + "smiley": {'code': '1f603', 'aliases': []}, + "smiley_cat": {'code': '1f63a', 'aliases': []}, + "smiling_devil": {'code': '1f608', 'aliases': ['smiling_imp', 'smiling_face_with_horns']}, + "smiling_face": {'code': '263a', 'aliases': ['relaxed']}, + "smiling_face_with_hearts": {'code': '1f970', 'aliases': ['adore', 'crush']}, + "smiling_face_with_tear": {'code': '1f972', 'aliases': ['grateful', 'smiling', 'tear', 'touched']}, + "smirk": {'code': '1f60f', 'aliases': ['smug']}, + "smirk_cat": {'code': '1f63c', 'aliases': ['smug_cat']}, + "smoking": {'code': '1f6ac', 'aliases': []}, + "snail": {'code': '1f40c', 'aliases': []}, + "snake": {'code': '1f40d', 'aliases': ['hiss']}, + "sneezing": {'code': '1f927', 'aliases': []}, + "snowboarder": {'code': '1f3c2', 'aliases': []}, + "snowflake": {'code': '2744', 'aliases': []}, + "snowman": {'code': '2603', 'aliases': []}, + "snowy": {'code': '1f328', 'aliases': ['snowstorm']}, + "snowy_mountain": {'code': '1f3d4', 'aliases': []}, + "soap": {'code': '1f9fc', 'aliases': ['bar', 'bathing', 'lather', 'soapdish']}, + "sob": {'code': '1f62d', 'aliases': []}, + "socks": {'code': '1f9e6', 'aliases': ['stocking']}, + "soft_serve": {'code': '1f366', 'aliases': ['soft_ice_cream']}, + "softball": {'code': '1f94e', 'aliases': ['glove', 'underarm']}, + "softer": {'code': '1f509', 'aliases': []}, + "soon": {'code': '1f51c', 'aliases': []}, + "sort": {'code': '1f5c2', 'aliases': []}, + "sos": {'code': '1f198', 'aliases': []}, + "space_invader": {'code': '1f47e', 'aliases': []}, + "spades": {'code': '2660', 'aliases': []}, + "spaghetti": {'code': '1f35d', 'aliases': []}, + "sparkle": {'code': '2747', 'aliases': []}, + "sparkler": {'code': '1f387', 'aliases': []}, + "sparkles": {'code': '2728', 'aliases': ['glamour']}, + "sparkling_heart": {'code': '1f496', 'aliases': []}, + "speak_no_evil": {'code': '1f64a', 'aliases': []}, + "speaker": {'code': '1f508', 'aliases': []}, + "speaking_head": {'code': '1f5e3', 'aliases': []}, + "speech_bubble": {'code': '1f5e8', 'aliases': []}, + "speechless": {'code': '1f636', 'aliases': ['no_mouth', 'blank', 'poker_face']}, + "speedboat": {'code': '1f6a4', 'aliases': []}, + "spider": {'code': '1f577', 'aliases': []}, + "spiral_calendar": {'code': '1f5d3', 'aliases': ['pad']}, + "spiral_notepad": {'code': '1f5d2', 'aliases': []}, + "spock": {'code': '1f596', 'aliases': ['live_long_and_prosper']}, + "sponge": {'code': '1f9fd', 'aliases': ['absorbing', 'porous']}, + "spoon": {'code': '1f944', 'aliases': []}, + "squared_ok": {'code': '1f197', 'aliases': []}, + "squared_up": {'code': '1f199', 'aliases': []}, + "squid": {'code': '1f991', 'aliases': []}, + "stadium": {'code': '1f3df', 'aliases': []}, + "star": {'code': '2b50', 'aliases': []}, + "star_and_crescent": {'code': '262a', 'aliases': ['islam']}, + "star_of_david": {'code': '2721', 'aliases': ['judaism']}, + "star_struck": {'code': '1f929', 'aliases': []}, + "station": {'code': '1f689', 'aliases': []}, + "statue": {'code': '1f5fd', 'aliases': ['new_york', 'statue_of_liberty']}, + "stethoscope": {'code': '1fa7a', 'aliases': []}, + "stock_market": {'code': '1f4b9', 'aliases': []}, + "stop": {'code': '1f91a', 'aliases': []}, + "stop_button": {'code': '23f9', 'aliases': []}, + "stop_sign": {'code': '1f6d1', 'aliases': ['octagonal_sign']}, + "stopwatch": {'code': '23f1', 'aliases': []}, + "strawberry": {'code': '1f353', 'aliases': []}, + "strike": {'code': '1f3b3', 'aliases': ['bowling']}, + "stuck_out_tongue": {'code': '1f61b', 'aliases': ['mischievous']}, + "stuck_out_tongue_closed_eyes": {'code': '1f61d', 'aliases': []}, + "stuck_out_tongue_wink": {'code': '1f61c', 'aliases': ['joking', 'crazy']}, + "student": {'code': '1f9d1-200d-1f393', 'aliases': []}, + "studio_microphone": {'code': '1f399', 'aliases': []}, + "suburb": {'code': '1f3e1', 'aliases': []}, + "subway": {'code': '1f687', 'aliases': []}, + "sun_face": {'code': '1f31e', 'aliases': []}, + "sunflower": {'code': '1f33b', 'aliases': []}, + "sunglasses": {'code': '1f60e', 'aliases': []}, + "sunny": {'code': '2600', 'aliases': []}, + "sunrise": {'code': '1f305', 'aliases': ['ocean_sunrise']}, + "sunset": {'code': '1f306', 'aliases': []}, + "sunshowers": {'code': '1f326', 'aliases': ['sun_and_rain', 'partly_sunny_with_rain']}, + "superhero": {'code': '1f9b8', 'aliases': []}, + "supervillain": {'code': '1f9b9', 'aliases': []}, + "surf": {'code': '1f3c4', 'aliases': []}, + "sushi": {'code': '1f363', 'aliases': []}, + "suspension_railway": {'code': '1f69f', 'aliases': []}, + "swan": {'code': '1f9a2', 'aliases': ['cygnet', 'ugly_duckling']}, + "sweat": {'code': '1f613', 'aliases': []}, + "sweat_drops": {'code': '1f4a6', 'aliases': []}, + "sweat_smile": {'code': '1f605', 'aliases': []}, + "swim": {'code': '1f3ca', 'aliases': []}, + "symbols": {'code': '1f523', 'aliases': []}, + "synagogue": {'code': '1f54d', 'aliases': []}, + "t_rex": {'code': '1f996', 'aliases': ['tyrannosaurus_rex']}, + "taco": {'code': '1f32e', 'aliases': []}, + "tada": {'code': '1f389', 'aliases': []}, + "take_off": {'code': '1f6eb', 'aliases': ['departure', 'airplane_departure']}, + "takeout_box": {'code': '1f961', 'aliases': ['oyster_pail']}, + "taking_a_picture": {'code': '1f4f8', 'aliases': ['say_cheese']}, + "tamale": {'code': '1fad4', 'aliases': ['mexican', 'wrapped']}, + "taurus": {'code': '2649', 'aliases': []}, + "taxi": {'code': '1f695', 'aliases': ['rideshare']}, + "tea": {'code': '1f375', 'aliases': []}, + "teacher": {'code': '1f9d1-200d-1f3eb', 'aliases': []}, + "teapot": {'code': '1fad6', 'aliases': []}, + "technologist": {'code': '1f9d1-200d-1f4bb', 'aliases': []}, + "teddy_bear": {'code': '1f9f8', 'aliases': ['plaything', 'plush', 'stuffed']}, + "telescope": {'code': '1f52d', 'aliases': []}, + "temperature": {'code': '1f321', 'aliases': ['thermometer', 'warm']}, + "tempura": {'code': '1f364', 'aliases': []}, + "ten": {'code': '1f51f', 'aliases': []}, + "tennis": {'code': '1f3be', 'aliases': []}, + "tent": {'code': '26fa', 'aliases': ['camping']}, + "test_tube": {'code': '1f9ea', 'aliases': ['chemistry']}, + "thinking": {'code': '1f914', 'aliases': []}, + "third_place": {'code': '1f949', 'aliases': ['bronze']}, + "thong_sandal": {'code': '1fa74', 'aliases': ['beach_sandals', 'sandals', 'thong_sandals', 'thongs', 'zōri', 'zori']}, + "thought": {'code': '1f4ad', 'aliases': ['dream']}, + "thread": {'code': '1f9f5', 'aliases': ['spool', 'string']}, + "three": {'code': '0033-20e3', 'aliases': []}, + "thunderstorm": {'code': '26c8', 'aliases': ['thunder_and_rain']}, + "ticket": {'code': '1f39f', 'aliases': []}, + "tie": {'code': '1f454', 'aliases': []}, + "tiger": {'code': '1f405', 'aliases': []}, + "tiger_cub": {'code': '1f42f', 'aliases': []}, + "time": {'code': '1f557', 'aliases': ['clock']}, + "time_ticking": {'code': '23f3', 'aliases': ['hourglass']}, + "timer": {'code': '23f2', 'aliases': []}, + "times_up": {'code': '231b', 'aliases': ['hourglass_done']}, + "tm": {'code': '2122', 'aliases': ['trademark']}, + "toilet": {'code': '1f6bd', 'aliases': []}, + "tomato": {'code': '1f345', 'aliases': []}, + "tongue": {'code': '1f445', 'aliases': []}, + "toolbox": {'code': '1f9f0', 'aliases': ['chest']}, + "tooth": {'code': '1f9b7', 'aliases': ['dentist']}, + "toothbrush": {'code': '1faa5', 'aliases': ['bathroom', 'brush', 'dental', 'hygiene', 'teeth']}, + "top": {'code': '1f51d', 'aliases': []}, + "top_hat": {'code': '1f3a9', 'aliases': []}, + "tornado": {'code': '1f32a', 'aliases': []}, + "tower": {'code': '1f5fc', 'aliases': ['tokyo_tower']}, + "trackball": {'code': '1f5b2', 'aliases': []}, + "tractor": {'code': '1f69c', 'aliases': []}, + "traffic_light": {'code': '1f6a6', 'aliases': ['vertical_traffic_light']}, + "train": {'code': '1f682', 'aliases': ['steam_locomotive']}, + "tram": {'code': '1f68b', 'aliases': ['streetcar']}, + "transgender_flag": {'code': '1f3f3-fe0f-200d-26a7-fe0f', 'aliases': ['light_blue', 'pink']}, + "transgender_symbol": {'code': '26a7', 'aliases': []}, + "tree": {'code': '1f333', 'aliases': ['deciduous_tree']}, + "triangular_flag": {'code': '1f6a9', 'aliases': []}, + "trident": {'code': '1f531', 'aliases': []}, + "triumph": {'code': '1f624', 'aliases': []}, + "troll": {'code': '1f9cc', 'aliases': ['fairy_tale', 'fantasy', 'monster']}, + "trolley": {'code': '1f68e', 'aliases': []}, + "trophy": {'code': '1f3c6', 'aliases': ['winner']}, + "tropical_drink": {'code': '1f379', 'aliases': []}, + "tropical_fish": {'code': '1f420', 'aliases': []}, + "truck": {'code': '1f69b', 'aliases': ['tractor-trailer', 'big_rig', 'semi_truck', 'transport_truck']}, + "trumpet": {'code': '1f3ba', 'aliases': []}, + "tulip": {'code': '1f337', 'aliases': ['flower']}, + "turban": {'code': '1f473', 'aliases': []}, + "turkey": {'code': '1f983', 'aliases': []}, + "turtle": {'code': '1f422', 'aliases': ['tortoise']}, + "tuxedo": {'code': '1f935', 'aliases': []}, + "tv": {'code': '1f4fa', 'aliases': ['television']}, + "two": {'code': '0032-20e3', 'aliases': []}, + "two_hearts": {'code': '1f495', 'aliases': []}, + "two_men_holding_hands": {'code': '1f46c', 'aliases': ['men_couple']}, + "two_women_holding_hands": {'code': '1f46d', 'aliases': ['women_couple']}, + "umbrella": {'code': '2602', 'aliases': []}, + "umbrella_with_rain": {'code': '2614', 'aliases': []}, + "umm": {'code': '1f4ac', 'aliases': ['speech_balloon']}, + "unamused": {'code': '1f612', 'aliases': []}, + "underage": {'code': '1f51e', 'aliases': ['nc17']}, + "unicorn": {'code': '1f984', 'aliases': []}, + "unlocked": {'code': '1f513', 'aliases': []}, + "unread_mail": {'code': '1f4ec', 'aliases': []}, + "up": {'code': '2b06', 'aliases': ['north']}, + "up_down": {'code': '2195', 'aliases': []}, + "upper_left": {'code': '2196', 'aliases': ['north_west']}, + "upper_right": {'code': '2197', 'aliases': ['north_east']}, + "upside_down": {'code': '1f643', 'aliases': ['oops']}, + "upvote": {'code': '1f53c', 'aliases': ['up_button', 'increase']}, + "vampire": {'code': '1f9db', 'aliases': []}, + "vase": {'code': '1f3fa', 'aliases': ['amphora']}, + "vhs": {'code': '1f4fc', 'aliases': ['videocassette']}, + "vibration_mode": {'code': '1f4f3', 'aliases': []}, + "video_camera": {'code': '1f4f9', 'aliases': ['video_recorder']}, + "video_game": {'code': '1f3ae', 'aliases': []}, + "violin": {'code': '1f3bb', 'aliases': []}, + "virgo": {'code': '264d', 'aliases': []}, + "volcano": {'code': '1f30b', 'aliases': []}, + "volleyball": {'code': '1f3d0', 'aliases': []}, + "volume": {'code': '1f39a', 'aliases': ['level_slider']}, + "vs": {'code': '1f19a', 'aliases': []}, + "waffle": {'code': '1f9c7', 'aliases': ['indecisive', 'iron']}, + "wait_one_second": {'code': '261d', 'aliases': ['point_of_information', 'asking_a_question']}, + "walking": {'code': '1f6b6', 'aliases': ['pedestrian']}, + "waning_crescent_moon": {'code': '1f318', 'aliases': []}, + "waning_gibbous_moon": {'code': '1f316', 'aliases': ['gibbous']}, + "warning": {'code': '26a0', 'aliases': ['caution', 'danger']}, + "wastebasket": {'code': '1f5d1', 'aliases': ['trash_can']}, + "watch": {'code': '231a', 'aliases': []}, + "water_buffalo": {'code': '1f403', 'aliases': []}, + "water_polo": {'code': '1f93d', 'aliases': []}, + "watermelon": {'code': '1f349', 'aliases': []}, + "wave": {'code': '1f44b', 'aliases': ['hello', 'hi']}, + "wavy_dash": {'code': '3030', 'aliases': []}, + "waxing_crescent_moon": {'code': '1f312', 'aliases': ['waxing']}, + "waxing_moon": {'code': '1f314', 'aliases': []}, + "wc": {'code': '1f6be', 'aliases': ['water_closet']}, + "weary": {'code': '1f629', 'aliases': ['distraught']}, + "web": {'code': '1f578', 'aliases': ['spider_web']}, + "wedding": {'code': '1f492', 'aliases': []}, + "whale": {'code': '1f433', 'aliases': []}, + "wheel": {'code': '1f6de', 'aliases': ['tire', 'turn']}, + "wheel_of_dharma": {'code': '2638', 'aliases': ['buddhism']}, + "white_and_black_square": {'code': '1f532', 'aliases': []}, + "white_cane": {'code': '1f9af', 'aliases': []}, + "white_circle": {'code': '26aa', 'aliases': []}, + "white_flag": {'code': '1f3f3', 'aliases': ['surrender']}, + "white_flower": {'code': '1f4ae', 'aliases': []}, + "white_heart": {'code': '1f90d', 'aliases': []}, + "white_large_square": {'code': '2b1c', 'aliases': []}, + "white_medium_small_square": {'code': '25fd', 'aliases': []}, + "white_medium_square": {'code': '25fb', 'aliases': []}, + "white_small_square": {'code': '25ab', 'aliases': []}, + "wilted_flower": {'code': '1f940', 'aliases': ['crushed']}, + "wind_chime": {'code': '1f390', 'aliases': []}, + "window": {'code': '1fa9f', 'aliases': ['frame', 'fresh_air', 'opening', 'transparent', 'view']}, + "windy": {'code': '1f32c', 'aliases': ['mother_nature']}, + "wine": {'code': '1f377', 'aliases': []}, + "wink": {'code': '1f609', 'aliases': []}, + "wish_tree": {'code': '1f38b', 'aliases': ['tanabata_tree']}, + "wolf": {'code': '1f43a', 'aliases': []}, + "woman": {'code': '1f469', 'aliases': []}, + "woman_artist": {'code': '1f469-200d-1f3a8', 'aliases': []}, + "woman_astronaut": {'code': '1f469-200d-1f680', 'aliases': []}, + "woman_bald": {'code': '1f469-200d-1f9b2', 'aliases': []}, + "woman_beard": {'code': '1f9d4-200d-2640', 'aliases': []}, + "woman_biking": {'code': '1f6b4-200d-2640', 'aliases': []}, + "woman_blond_hair": {'code': '1f471-200d-2640', 'aliases': ['blond_haired_woman', 'blonde']}, + "woman_bouncing_ball": {'code': '26f9-fe0f-200d-2640-fe0f', 'aliases': []}, + "woman_bowing": {'code': '1f647-200d-2640', 'aliases': []}, + "woman_cartwheeling": {'code': '1f938-200d-2640', 'aliases': []}, + "woman_climbing": {'code': '1f9d7-200d-2640', 'aliases': []}, + "woman_construction_worker": {'code': '1f477-200d-2640', 'aliases': []}, + "woman_cook": {'code': '1f469-200d-1f373', 'aliases': []}, + "woman_curly_hair": {'code': '1f469-200d-1f9b1', 'aliases': []}, + "woman_detective": {'code': '1f575-fe0f-200d-2640-fe0f', 'aliases': []}, + "woman_elf": {'code': '1f9dd-200d-2640', 'aliases': []}, + "woman_facepalming": {'code': '1f926-200d-2640', 'aliases': []}, + "woman_factory_worker": {'code': '1f469-200d-1f3ed', 'aliases': []}, + "woman_fairy": {'code': '1f9da-200d-2640', 'aliases': []}, + "woman_farmer": {'code': '1f469-200d-1f33e', 'aliases': []}, + "woman_feeding_baby": {'code': '1f469-200d-1f37c', 'aliases': []}, + "woman_firefighter": {'code': '1f469-200d-1f692', 'aliases': []}, + "woman_frowning": {'code': '1f64d-200d-2640', 'aliases': []}, + "woman_genie": {'code': '1f9de-200d-2640', 'aliases': []}, + "woman_gesturing_no": {'code': '1f645-200d-2640', 'aliases': []}, + "woman_gesturing_ok": {'code': '1f646-200d-2640', 'aliases': []}, + "woman_getting_haircut": {'code': '1f487-200d-2640', 'aliases': []}, + "woman_getting_massage": {'code': '1f486-200d-2640', 'aliases': []}, + "woman_golfing": {'code': '1f3cc-fe0f-200d-2640-fe0f', 'aliases': []}, + "woman_guard": {'code': '1f482-200d-2640', 'aliases': []}, + "woman_health_worker": {'code': '1f469-200d-2695', 'aliases': []}, + "woman_in_lotus_position": {'code': '1f9d8-200d-2640', 'aliases': []}, + "woman_in_manual_wheelchair": {'code': '1f469-200d-1f9bd', 'aliases': []}, + "woman_in_motorized_wheelchair": {'code': '1f469-200d-1f9bc', 'aliases': []}, + "woman_in_steamy_room": {'code': '1f9d6-200d-2640', 'aliases': []}, + "woman_in_tuxedo": {'code': '1f935-200d-2640', 'aliases': []}, + "woman_judge": {'code': '1f469-200d-2696', 'aliases': []}, + "woman_juggling": {'code': '1f939-200d-2640', 'aliases': []}, + "woman_kneeling": {'code': '1f9ce-200d-2640', 'aliases': []}, + "woman_lifting_weights": {'code': '1f3cb-fe0f-200d-2640-fe0f', 'aliases': []}, + "woman_mage": {'code': '1f9d9-200d-2640', 'aliases': []}, + "woman_mechanic": {'code': '1f469-200d-1f527', 'aliases': []}, + "woman_mountain_biking": {'code': '1f6b5-200d-2640', 'aliases': []}, + "woman_office_worker": {'code': '1f469-200d-1f4bc', 'aliases': []}, + "woman_pilot": {'code': '1f469-200d-2708', 'aliases': []}, + "woman_playing_handball": {'code': '1f93e-200d-2640', 'aliases': []}, + "woman_playing_water_polo": {'code': '1f93d-200d-2640', 'aliases': []}, + "woman_police_officer": {'code': '1f46e-200d-2640', 'aliases': []}, + "woman_pouting": {'code': '1f64e-200d-2640', 'aliases': []}, + "woman_raising_hand": {'code': '1f64b-200d-2640', 'aliases': []}, + "woman_red_hair": {'code': '1f469-200d-1f9b0', 'aliases': []}, + "woman_rowing_boat": {'code': '1f6a3-200d-2640', 'aliases': []}, + "woman_running": {'code': '1f3c3-200d-2640', 'aliases': []}, + "woman_scientist": {'code': '1f469-200d-1f52c', 'aliases': []}, + "woman_shrugging": {'code': '1f937-200d-2640', 'aliases': []}, + "woman_singer": {'code': '1f469-200d-1f3a4', 'aliases': []}, + "woman_standing": {'code': '1f9cd-200d-2640', 'aliases': []}, + "woman_student": {'code': '1f469-200d-1f393', 'aliases': []}, + "woman_superhero": {'code': '1f9b8-200d-2640', 'aliases': []}, + "woman_supervillain": {'code': '1f9b9-200d-2640', 'aliases': []}, + "woman_surfing": {'code': '1f3c4-200d-2640', 'aliases': []}, + "woman_swimming": {'code': '1f3ca-200d-2640', 'aliases': []}, + "woman_teacher": {'code': '1f469-200d-1f3eb', 'aliases': []}, + "woman_technologist": {'code': '1f469-200d-1f4bb', 'aliases': []}, + "woman_tipping_hand": {'code': '1f481-200d-2640', 'aliases': []}, + "woman_vampire": {'code': '1f9db-200d-2640', 'aliases': []}, + "woman_walking": {'code': '1f6b6-200d-2640', 'aliases': []}, + "woman_wearing_turban": {'code': '1f473-200d-2640', 'aliases': []}, + "woman_white_hair": {'code': '1f469-200d-1f9b3', 'aliases': []}, + "woman_with_headscarf": {'code': '1f9d5', 'aliases': ['headscarf', 'hijab', 'mantilla', 'tichel']}, + "woman_with_veil": {'code': '1f470-200d-2640', 'aliases': []}, + "woman_with_white_cane": {'code': '1f469-200d-1f9af', 'aliases': []}, + "woman_zombie": {'code': '1f9df-200d-2640', 'aliases': []}, + "women_with_bunny_ears": {'code': '1f46f-200d-2640', 'aliases': []}, + "women_wrestling": {'code': '1f93c-200d-2640', 'aliases': []}, + "womens": {'code': '1f6ba', 'aliases': []}, + "wood": {'code': '1fab5', 'aliases': ['log', 'timber']}, + "woozy_face": {'code': '1f974', 'aliases': ['intoxicated', 'tipsy', 'uneven_eyes', 'wavy_mouth']}, + "work_in_progress": {'code': '1f6a7', 'aliases': ['construction_zone']}, + "working_on_it": {'code': '1f6e0', 'aliases': ['hammer_and_wrench', 'tools']}, + "worm": {'code': '1fab1', 'aliases': ['annelid', 'earthworm', 'parasite']}, + "worried": {'code': '1f61f', 'aliases': []}, + "wrestling": {'code': '1f93c', 'aliases': []}, + "writing": {'code': '270d', 'aliases': []}, + "www": {'code': '1f310', 'aliases': ['globe']}, + "x": {'code': '274e', 'aliases': []}, + "x_ray": {'code': '1fa7b', 'aliases': ['bones', 'medical']}, + "yam": {'code': '1f360', 'aliases': ['sweet_potato']}, + "yarn": {'code': '1f9f6', 'aliases': ['crochet', 'knit']}, + "yawning_face": {'code': '1f971', 'aliases': ['bored', 'yawn']}, + "yellow_circle": {'code': '1f7e1', 'aliases': ['yellow']}, + "yellow_heart": {'code': '1f49b', 'aliases': ['heart_of_gold']}, + "yellow_large_square": {'code': '1f7e8', 'aliases': []}, + "yen_banknotes": {'code': '1f4b4', 'aliases': []}, + "yin_yang": {'code': '262f', 'aliases': []}, + "yo_yo": {'code': '1fa80', 'aliases': ['fluctuate']}, + "yum": {'code': '1f60b', 'aliases': []}, + "zany_face": {'code': '1f92a', 'aliases': ['goofy', 'small']}, + "zebra": {'code': '1f993', 'aliases': ['stripe']}, + "zero": {'code': '0030-20e3', 'aliases': []}, + "zombie": {'code': '1f9df', 'aliases': []}, + "zzz": {'code': '1f4a4', 'aliases': []}, +} # fmt: on diff --git a/zulipterminal/version.py b/zulipterminal/version.py index 38597dcf19..2dee75ef36 100644 --- a/zulipterminal/version.py +++ b/zulipterminal/version.py @@ -5,7 +5,7 @@ ZT_VERSION = "0.7.0+git" SUPPORTED_SERVER_VERSIONS = [ - ("2.1", None), + ("2.1", 0), ("3.0", 25), ] diff --git a/zulipterminal/widget.py b/zulipterminal/widget.py new file mode 100644 index 0000000000..05e80c7c97 --- /dev/null +++ b/zulipterminal/widget.py @@ -0,0 +1,76 @@ +""" +Process widgets (submessages) like polls, todo lists, etc. +""" + +import json +from typing import Dict, List, Tuple, Union + + +Submessage = Dict[str, Union[int, str]] + + +def find_widget_type(submessages: List[Submessage]) -> str: + if submessages and "content" in submessages[0]: + content = submessages[0]["content"] + + if isinstance(content, str): + try: + loaded_content = json.loads(content) + return loaded_content.get("widget_type", "unknown") + except json.JSONDecodeError: + return "unknown" + else: + return "unknown" + else: + return "unknown" + + +def process_todo_widget( + todo_list: List[Submessage], +) -> Tuple[str, Dict[str, Dict[str, Union[str, bool]]]]: + title = "" + tasks = {} + + for entry in todo_list: + content = entry.get("content") + sender_id = entry.get("sender_id") + msg_type = entry.get("msg_type") + + if msg_type == "widget" and isinstance(content, str): + widget = json.loads(content) + + if widget.get("widget_type") == "todo": + if "extra_data" in widget and widget["extra_data"] is not None: + title = widget["extra_data"].get("task_list_title", "") + if title == "": + # Webapp uses "Task list" as default title + title = "Task list" + # Process initial tasks + for i, task in enumerate(widget["extra_data"].get("tasks", [])): + # Initial tasks get ID as "index,canned" + task_id = f"{i},canned" + tasks[task_id] = { + "task": task["task"], + "desc": task.get("desc", ""), + "completed": False, + } + + elif widget.get("type") == "new_task": + # New tasks get ID as "key,sender_id" + task_id = f"{widget['key']},{sender_id}" + tasks[task_id] = { + "task": widget["task"], + "desc": widget.get("desc", ""), + "completed": False, + } + + elif widget.get("type") == "strike": + # Strike event - toggle task completion state + task_id = widget["key"] + if task_id in tasks: + tasks[task_id]["completed"] = not tasks[task_id]["completed"] + + elif widget.get("type") == "new_task_list_title": + title = widget["title"] + + return title, tasks