Repository: clarkio/vscode-twitch-highlighter
Branch: main
Commit: 6569f8b9a260
Files: 60
Total size: 99.8 KB
Directory structure:
gitextract_oxdixv9m/
├── .all-contributorsrc
├── .editorconfig
├── .gitattributes
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ └── bug_report.md
│ └── workflows/
│ ├── ci-pipeline.yml
│ ├── codeql-analysis.yml
│ └── deploy.yml
├── .gitignore
├── .vscode/
│ ├── extensions.json
│ ├── launch.json
│ ├── settings.json
│ └── tasks.json
├── .vscodeignore
├── CHANGELOG.md
├── README.md
├── build.yml
├── package.json
├── src/
│ ├── api.ts
│ ├── app.ts
│ ├── common/
│ │ ├── index.ts
│ │ └── keytar.ts
│ ├── constants.ts
│ ├── credentialManager.ts
│ ├── enums/
│ │ ├── AppContexts.ts
│ │ ├── Commands.ts
│ │ ├── Configuration.ts
│ │ ├── InternalCommands.ts
│ │ ├── KeytarKeys.ts
│ │ ├── LogLevel.ts
│ │ ├── Settings.ts
│ │ ├── TwitchKeys.ts
│ │ └── index.ts
│ ├── extension.ts
│ ├── highlight/
│ │ ├── Highlight.ts
│ │ ├── HighlightManager.ts
│ │ ├── index.ts
│ │ └── treeView/
│ │ ├── HighlightTreeDataProvider.ts
│ │ ├── HighlightTreeItem.ts
│ │ └── index.ts
│ ├── index.ts
│ ├── logger.ts
│ ├── test/
│ │ ├── runTest.ts
│ │ └── suite/
│ │ ├── extension.test.ts
│ │ ├── index.ts
│ │ └── utils.test.ts
│ ├── ttvchat/
│ │ ├── AuthenticationService.ts
│ │ ├── ChatClient.ts
│ │ ├── TwitchChatService.ts
│ │ ├── api/
│ │ │ ├── API.ts
│ │ │ └── index.ts
│ │ ├── index.ts
│ │ └── login/
│ │ └── index.htm
│ └── utils/
│ ├── getNodeModule.ts
│ ├── index.ts
│ ├── isEnum.ts
│ ├── naturalCompare.ts
│ └── parseMessage.ts
├── tsconfig.json
├── tslint.json
└── webpack.config.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .all-contributorsrc
================================================
{
"files": [
"README.md"
],
"imageSize": 100,
"commit": false,
"contributors": [
{
"login": "parithon",
"name": "Anthony Conrad (parithon)",
"avatar_url": "https://avatars.githubusercontent.com/u/8602418?v=4",
"profile": "https://github.com/parithon",
"contributions": [
"code"
]
},
{
"login": "MatthewKosloski",
"name": "Matthew Kosloski",
"avatar_url": "https://avatars.githubusercontent.com/u/1219553?v=4",
"profile": "https://matthewkosloski.me/",
"contributions": [
"code"
]
},
{
"login": "yoannfleurydev",
"name": "Yoann Fleury",
"avatar_url": "https://avatars.githubusercontent.com/u/3920615?v=4",
"profile": "http://blog.yoannfleury.dev",
"contributions": [
"code"
]
},
{
"login": "Technickel-Dev",
"name": "Technickel",
"avatar_url": "https://avatars.githubusercontent.com/u/22779812?v=4",
"profile": "https://www.technickel.dev/",
"contributions": [
"code",
"test"
]
}
],
"contributorsPerLine": 7,
"projectName": "vscode-twitch-highlighter",
"projectOwner": "clarkio",
"repoType": "github",
"repoHost": "https://github.com",
"skipCi": true,
"commitConvention": "angular"
}
================================================
FILE: .editorconfig
================================================
# http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
# Use 4 spaces for the Python files
[*.py]
indent_size = 4
max_line_length = 80
# The JSON files contain newlines inconsistently
[*.json]
insert_final_newline = ignore
# Minified JavaScript files shouldn't be changed
[**.min.js]
indent_style = ignore
insert_final_newline = ignore
# Makefiles always use tabs for indentation
[Makefile]
indent_style = tab
# Batch files use tabs for indentation
[*.bat]
indent_style = tab
[*.md]
trim_trailing_whitespace = false
================================================
FILE: .gitattributes
================================================
# Set default behavior to automatically normalize line endings.
* text=auto
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**VSCode Version**
**Additional context**
Add any other context about the problem here.
================================================
FILE: .github/workflows/ci-pipeline.yml
================================================
name: "Build and Test"
on:
pull_request:
branches:
- vnext
- main
jobs:
build:
strategy:
matrix:
os: [macos-latest, ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version: 16.x
- run: npm install
- run: xvfb-run -a npm test
if: runner.os == 'Linux'
- run: npm test
if: runner.os != 'Linux'
================================================
FILE: .github/workflows/codeql-analysis.yml
================================================
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ vnext, main ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ vnext ]
schedule:
- cron: '34 9 * * 5'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
================================================
FILE: .github/workflows/deploy.yml
================================================
name: "Deploy to Registries"
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: 16
- run: npm ci
- name: Publish to Open VSX Registry
uses: HaaLeo/publish-vscode-extension@v1
with:
pat: ${{ secrets.OPEN_VSX_TOKEN }}
- name: Publish to Visual Studio Marketplace
uses: HaaLeo/publish-vscode-extension@v1
with:
pat: ${{ secrets.VSCE_PAT }}
registryUrl: https://marketplace.visualstudio.com
================================================
FILE: .gitignore
================================================
out
node_modules
types
.vscode-test/
*.vsix
.dccache
================================================
FILE: .vscode/extensions.json
================================================
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"eg2.tslint"
]
}
================================================
FILE: .vscode/launch.json
================================================
// A launch configuration that compiles the extension and then opens it inside a new window
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
{
"version": "0.2.0",
"configurations": [
{
"name": "Extension",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
],
"outFiles": ["${workspaceFolder}/out/**/*.js"],
"preLaunchTask": "npm: watch"
},
{
"name": "Extension Disable Other Extensions",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"--disable-extensions"
],
"outFiles": ["${workspaceFolder}/out/**/*.js"],
"preLaunchTask": "npm: watch"
},
{
"type": "node",
"request": "attach",
"name": "Attach to Server",
"port": 6009,
"timeout": 10000,
"restart": true,
"outFiles": ["${workspaceRoot}/out/**/*.js"]
},
{
"name": "Extension Tests",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"--extensionTestsPath=${workspaceFolder}/out/test/suite/index",
"--disable-extensions"
],
"outFiles": ["${workspaceFolder}/out/test/**/*.js"],
"preLaunchTask": "npm: watch"
}
],
"compounds": [
{
"name": "Extension + LSP",
"configurations": ["Attach to Server", "Extension"]
}
]
}
================================================
FILE: .vscode/settings.json
================================================
// Place your settings in this file to overwrite default and user settings.
{
"files.exclude": {
"out": false, // set this to true to hide the "out" folder with the compiled JS files
"node_modules": false
},
"search.exclude": {
"out": true, // set this to false to include "out" folder in search results
"node_modules": true
},
// Turn off tsc task auto detection since we have the necessary tasks as npm scripts
"typescript.tsc.autoDetect": "off",
"typescript.tsdk": "node_modules\\typescript\\lib",
"prettier.singleQuote": true
}
================================================
FILE: .vscode/tasks.json
================================================
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "watch",
"problemMatcher": "$tsc-watch",
"isBackground": true,
"presentation": {
"reveal": "never"
},
"group": {
"kind": "build",
"isDefault": true
}
}
]
}
================================================
FILE: .vscodeignore
================================================
.github/**
.vscode/**
.vscode-test/**
out/test/**
out/**/*.map
src/**
.gitignore
build.yml
tsconfig.json
vsc-extension-quickstart.md
tslint.json
node_modules/
webpack.config.js
================================================
FILE: CHANGELOG.md
================================================
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [Released]
## [1.0.6]
### Added
- Support to publish to Open VSX Registry
## [1.0.5]
### Changed
- To ignore .dccache file created in a project when using the Snyk extension or CLI for Snyk Code in [#171](https://github.com/clarkio/vscode-twitch-highlighter/pull/171)
- To newest npm package lock version in [#171](https://github.com/clarkio/vscode-twitch-highlighter/pull/171)
- Default branch to be `main` to be used for production releases and will continue to use `vnext` as the active development branch. Feature branches will still be used with PRs for them opened against `vnext` and then when we're ready to push a new release we'll open a PR from `vnext` to `main`.
### Fixed
- Use of login page to run on expected port of 5001 for OAuth redirect flows between VS Code and a browser when authenticating with Twitch in [#171](https://github.com/clarkio/vscode-twitch-highlighter/pull/171)
## [1.0.4]
### Changed
- Testing from vscode to @vscode/test-electron in [#153](https://github.com/clarkio/vscode-twitch-highlighter/pull/153)
## [1.0.3]
### Changed
- To new Twitch App Client Id
## [1.0.2]
### Security
- Addressed dependency from security audit for package `bl`: [bl npm advisory](https://npmjs.com/advisories/1555)
## [1.0.1]
### Fixed
- Reading of extension settings by name: [commit](https://github.com/clarkio/vscode-twitch-highlighter/commit/5b6844bd999d5eefb5ec186f76510b0a62704d4e)
## [1.0.0]
First full release and no longer in preview!
### Added
- Export an API for others to create an extension that ties into YouTube, Mixer, etc.
- Ability to remove highlights using a context-menu.
- Functionality for Highlights to adjust when editing the text around the highlight
### Changed
- We now use Twitch's OAuth endpoint to generate an access_token and saving the token into your credential manager.
### Removed
- Need to manually generate an OAuth token that you enter into a prompt when logging in for this extension
### Security
- [Address vulnerability for dependency 'elliptic'](https://github.com/clarkio/vscode-twitch-highlighter/commit/9150f06a4290402a562c1404001ce7fa289efce3)
## [0.5.1]
### Added
- Context menu support to remove highlights. You can now remove highlights by right-clicking on them. Additionally, you can remove all highlights by right-clicking the editor tab.
## [0.5.0]
### Rewrite
This is a complete rewrite of the extension. The extension now has an API that others may use to build add-on extensions to extend to other chat services like Youtube or Mixer.
## [0.2.3]
### Fixed
- Issue where highlights were not visible in tree views ([110](https://github.com/clarkio/vscode-twitch-highlighter/pull/110) thanks @parithon)
## [0.2.2]
### Added
- Bot response to just `!line` in chat with instructions on how to use it ([96](https://github.com/clarkio/vscode-twitch-highlighter/pull/96) thanks @parithon)
### Changed
- Replaced all UI text and naming in code for "password" with "token" as that is more accurate to its use ([92](https://github.com/clarkio/vscode-twitch-highlighter/pull/92) thanks @parithon)
## [0.2.1]
### Added
- Template for issues ([90](https://github.com/clarkio/vscode-twitch-highlighter/pull/90) thanks @yoannfleurydev)
- Functionality to listen for ban events and remove highlights from banned users in Twitch chat ([99](https://github.com/clarkio/vscode-twitch-highlighter/pull/99) thanks @mpjme)
### Changed
- Updated README to remove duplicate animated gif ([85](https://github.com/clarkio/vscode-twitch-highlighter/pull/85) thanks @parithon)
- Switched from twitch-js library to tmi.js ([97](https://github.com/clarkio/vscode-twitch-highlighter/pull/97) thanks @parithon)
### Fixed
- Inaccurate status in status bar when a token is not provided on initial start up ([91](https://github.com/clarkio/vscode-twitch-highlighter/pull/91) thanks @parithon)
## [0.2.0]
### Added
- Highlights tree view in explorer and debug views ([83](https://github.com/clarkio/vscode-twitch-highlighter/pull/83) thanks @parithon)
- More tests using test theory inputs ([79](https://github.com/clarkio/vscode-twitch-highlighter/pull/79) thanks @parithon)
### Changed
- Location of the button to connect/disconnect the highlight from the chat channel to the left ([83](https://github.com/clarkio/vscode-twitch-highlighter/pull/83) thanks @parithon). This was moved to follow the convention that most extensions which are app based add to the left side of the status bar and the right side is reserved more for editor formatting type actions.
- Icon in activity to be hidden but can be shown via the setting `twitchHighlighter.showHighlightsInActivityBar` ([83](https://github.com/clarkio/vscode-twitch-highlighter/pull/83) thanks @parithon)
## [0.1.5]
### Added
- Ability to unhighlight on disconnect ([#67](https://github.com/clarkio/vscode-twitch-highlighter/pull/67) thanks @parithon)
- Option to highlight multiple lines via one command as well as a comment message ([#68](https://github.com/clarkio/vscode-twitch-highlighter/pull/68) thanks @parithon)
- DevOps badges to show status of development and production builds. Plus media to demonstrate the extension in use and a multi-line highlight example ([#75](https://github.com/clarkio/vscode-twitch-highlighter/pull/75) thanks @parithon)
## [0.1.4]
### Added
- A setting to allow changes to font color within highlights ([#55](https://github.com/clarkio/vscode-twitch-highlighter/pull/55))
- An .editorconfig file to keep styling consistent. ([#54](https://github.com/clarkio/vscode-twitch-highlighter/pull/54) thanks @parithon)
- Arbitrary tests to get things started in that area ([#69](https://github.com/clarkio/vscode-twitch-highlighter/pull/69) thanks @parithon)
- Use of webpack to significantly reduce the size of the extension and improve the install speed ([#66](https://github.com/clarkio/vscode-twitch-highlighter/pull/66) thanks @parithon)
### Changed
- Client code with some refactoring to clean it up ([#57](https://github.com/clarkio/vscode-twitch-highlighter/pull/57) thanks @parithon)
- Commands to use categories for grouping of them ([#63](https://github.com/clarkio/vscode-twitch-highlighter/pull/63) thanks @matthewkosloski)
### Removed
- Use of Twitch Glitch logo in the VS Code marketplace and in the activity bar icon. See PR for more details on why ([#65](https://github.com/clarkio/vscode-twitch-highlighter/pull/65) thanks @parithon)
## [0.1.3] - 2019-02-07
### Changed
- Setting names to be camelCase ([#48](https://github.com/clarkio/vscode-twitch-highlighter/pull/48))
- README with better instructions to get started ([#43](https://github.com/clarkio/vscode-twitch-highlighter/pull/43) thanks @FletcherCodes)
- Icon used in the VS Code Marketplace for better contrast/visibility ([#50](https://github.com/clarkio/vscode-twitch-highlighter/pull/50) thanks @parithon)
## [0.1.2] - 2019-02-03
### Fixed
- Issues where commands were not registering. The cause was from node_modules not being included in the package.
## 0.1.0 - 2019-02-01
- Pre-release version to gather feedback from the community and help identify gaps.
[1.0.6]: https://github.com/clarkio/vscode-twitch-highlighter/compare/1.0.5...1.0.6
[1.0.5]: https://github.com/clarkio/vscode-twitch-highlighter/compare/1.0.4...1.0.5
[1.0.4]: https://github.com/clarkio/vscode-twitch-highlighter/compare/1.0.2...1.0.4
[1.0.2]: https://github.com/clarkio/vscode-twitch-highlighter/compare/1.0.1...1.0.2
[1.0.1]: https://github.com/clarkio/vscode-twitch-highlighter/compare/1.0.0...1.0.1
[1.0.0]: https://github.com/clarkio/vscode-twitch-highlighter/compare/0.5.1...1.0.0
[0.5.1]: https://github.com/clarkio/vscode-twitch-highlighter/compare/0.5.0...0.5.1
[0.5.0]: https://github.com/clarkio/vscode-twitch-highlighter/compare/0.2.3...0.5.0
[0.2.3]: https://github.com/clarkio/vscode-twitch-highlighter/compare/0.2.2...0.2.3
[0.2.2]: https://github.com/clarkio/vscode-twitch-highlighter/compare/0.2.1...0.2.2
[0.2.1]: https://github.com/clarkio/vscode-twitch-highlighter/compare/0.2.0...0.2.1
[0.2.0]: https://github.com/clarkio/vscode-twitch-highlighter/compare/0.1.5...0.2.0
[0.1.5]: https://github.com/clarkio/vscode-twitch-highlighter/compare/0.1.4...0.1.5
[0.1.4]: https://github.com/clarkio/vscode-twitch-highlighter/compare/0.1.3...0.1.4
[0.1.3]: https://github.com/clarkio/vscode-twitch-highlighter/compare/0.1.2...0.1.3
[0.1.2]: https://github.com/clarkio/vscode-twitch-highlighter/compare/b28e5041ac...0.1.2
================================================
FILE: README.md
================================================
# Twitch Line Highlighter VS Code Extension
[](#contributors-)
[](https://snyk.io/test/github/clarkio/vscode-twitch-highlighter)
[](https://discord.gg/xB95beJ)
[](https://twitch.tv/clarkio)
[](https://github.com/clarkio/vscode-twitch-highlighter/actions/workflows/ci-pipeline.yml)
[](https://twitter.com/intent/follow?screen_name=_clarkio)
A VS Code extension to allow your Twitch viewers to help in spotting bugs, typos, etc. by sending a command in chat that will highlight the line of code they want you to check.

[](#contributors-)
## Clarkio
This VS Code extension was built with 💙 live on stream with the programming community. Come and hang out with us over on Twitch!
> https://twitch.tv/clarkio
## Requirements
In order to use this extension you will need the following things before going to the [Getting Started](#getting-started) section:
- An installed version of [VS Code](https://code.visualstudio.com/?WT.mc_id=academic-0000-brcl)
- A Twitch account for yourself or a separate one to be used as a chat bot ([sign up here](https://www.twitch.tv/signup))
## Getting Started
1. Install the extension from the [marketplace](https://marketplace.visualstudio.com/items?itemName=clarkio.twitch-highlighter&WT.mc_id=academic-0000-brcl)
2. Open your VS Code settings
- Keyboard shortcut: `CTRL/CMD + ,`
3. Type in "twitch" into the search bar
4. Find the `Twitch Highlighter: Channels` setting and enter the name of the channel(s) to which you'd like the extension to connect. Example: `clarkio` If you'd like to connect to more than one channel separate them by commas `,`. Example: `clarkio,parithon`
5. Save your changes and close that tab. Go back to the Settings UI tab.
6. Find the `Nickname` setting. If you are using your own account for the chat bot then enter your account username as the value here. If you created a separate account use that username. Save your changes.
7. Make sure you're logged in to the Twitch account you wish to authorize the highlighter bot to access in your default browser.
8. In the status bar, click the "Twitch" button. After clicking it, you'll see a notification that the extension wants to open a URL.
9. Choose the "Open" option which should open a new tab of your default browser.
10. Read through the permissions that are being requested for use of this bot/extension and choose "Authorize"
11. You should then be notified that you can close the browser/tab
12. Go back to VS Code and you should now see "Disconnected" in the status bar. Click on it to Connect the bot to chat and start listening for highlight commands.
## Twitch Commands
To highlight a line, use:
!highlight OR !line
To unhighlight a line, use:
!line !
To highlight multiple lines, use the same syntax as above but include a range of lines to highlight:
!line -
Additionally, you can also include comments:
!line This is a comment

## Extension Settings
- `twitchHighlighter.channels`: A comma separated list of channel name(s) to connect to on Twitch. Example: 'clarkio', Another Example: 'clarkio, parithon'
- `twitchHighlighter.nickname`: The username the bot should use when joining a Twitch channel.
> Note: this is required if you'd like to have the bot send join/leave messages in your chat. It also needs to match the Twitch username with which you generated the OAuth token.
- `twitchHighlighter.highlightColor`: Background color of the decoration (default: green). Use rgba() and define transparent background colors to play well with other decorations.
Example: green
- `twitchHighlighter.highlightFontColor`: Font color of the decoration (default: white). Use rgba() and define transparent background colors to play well with other decorations.
Example: white
* `twitchHighlighter.highlightBorder`: CSS styling property that will be applied to text enclosed by a decoration.
* `twitchHighlighter.announceBot`: Whether or not the bot should announce its joining or leaving the chat room.
* `twitchHighlighter.joinMessage`: The message the bot will say when joining a chat room
* `twitchHighlighter.leaveMessage`: The message the bot will say when leaving a chat room
* `twitchHighlighter.showHighlightsInActivityBar`: Show the Highlights icon in the activity bar to display the tree view.
* `twitchHighlighter.usageTip`: A tip shared by the bot when a user chats: '!line'.
## Attribution
Some of the code in this extension has been adapted from the [twitchlint extension](https://github.com/irth/twitchlint) built by [@irth](https://github.com/irth)
## Known Issues
- Extension doesn't allow specifying the file to put the highlight in. This is a work in progress.
## Release Notes
See [CHANGELOG.md](CHANGELOG.md)
## Contributors ✨
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
================================================
FILE: build.yml
================================================
jobs:
- job: Windows
pool:
name: Hosted VS2017
demands: npm
steps:
- task: NodeTool@0
displayName: 'Use Node 8.x'
inputs:
versionSpec: 8.x
- task: Npm@1
displayName: 'Install dependencies'
inputs:
verbose: false
- task: Npm@1
displayName: 'Compile sources'
inputs:
command: custom
verbose: false
customCommand: 'run compile'
- script: 'node node_modules/vscode/bin/test'
displayName: 'Run tests'
- job: macOS
pool:
name: Hosted macOS
demands: npm
steps:
- task: NodeTool@0
displayName: 'Use Node 8.x'
inputs:
versionSpec: 8.x
- task: Npm@1
displayName: 'Install dependencies'
inputs:
verbose: false
- task: Npm@1
displayName: 'Compile sources'
inputs:
command: custom
verbose: false
customCommand: 'run compile'
- script: 'node node_modules/vscode/bin/test'
displayName: 'Run tests'
- job: Linux
pool:
name: Hosted Ubuntu 1604
demands: npm
steps:
- task: NodeTool@0
displayName: 'Use Node 8.x'
inputs:
versionSpec: 8.x
- task: Npm@1
displayName: 'Install dependencies'
inputs:
verbose: false
- task: Npm@1
displayName: 'Compile sources'
inputs:
command: custom
verbose: false
customCommand: 'run compile'
- script: |
set -e
/usr/bin/Xvfb :10 -ac >> /tmp/Xvfb.out 2>&1 &
disown -ar
displayName: 'Start xvfb'
- script: 'node node_modules/vscode/bin/test'
displayName: 'Run tests'
env:
DISPLAY: :10
================================================
FILE: package.json
================================================
{
"name": "twitch-highlighter",
"displayName": "Twitch Highlighter",
"description": "Allow your Twitch viewers to help in spotting bugs, typos, etc. by sending a command in chat that will highlight the line of code they want you to check.",
"version": "1.0.6",
"preview": false,
"publisher": "clarkio",
"engines": {
"vscode": "^1.31.0"
},
"icon": "resources/highlighterIcon.png",
"categories": [
"Other"
],
"activationEvents": [
"*"
],
"repository": {
"type": "git",
"url": "https://github.com/clarkio/vscode-twitch-highlighter.git"
},
"license": "MIT",
"main": "./out/extension.js",
"contributes": {
"commands": [
{
"command": "twitchHighlighter.refreshTreeView",
"title": "Refresh"
},
{
"command": "twitchHighlighter.highlight",
"title": "Highlight Line",
"category": "Twitch Highlighter"
},
{
"command": "twitchHighlighter.unhighlight",
"title": "Remove Highlight"
},
{
"command": "twitchHighlighter.unhighlightSpecific",
"title": "Unhighlight by File and Line",
"category": "Twitch Highlighter"
},
{
"command": "twitchHighlighter.unhighlightAll",
"title": "Remove All Highlights",
"category": "Twitch Highlighter"
},
{
"command": "twitchHighlighter.gotoHighlight",
"title": "Goto Highlight"
},
{
"command": "twitchHighlighter.requestHighlight",
"title": "Request a Highlight from the Twitch Chat Client",
"category": "Twitch Highlighter"
},
{
"command": "twitchHighlighter.requestUnhighlight",
"title": "Request an Unhighlight from the Twitch Chat Client",
"category": "Twitch Highlighter"
},
{
"command": "twitchHighlighter.requestUnhighlightAll",
"title": "Request to unhighlight all highlights requested by the Twitch Chat Client",
"category": "Twitch Highlighter"
},
{
"command": "twitchHighlighter.signIn",
"title": "Sign-in to Twitch",
"category": "Twitch Highlighter"
},
{
"command": "twitchHighlighter.signOut",
"title": "Sign-out of Twitch",
"category": "Twitch Highlighter"
},
{
"command": "twitchHighlighter.connect",
"title": "Start Listening to Chat",
"category": "Twitch Highlighter"
},
{
"command": "twitchHighlighter.disconnect",
"title": "Stop Listening to Chat",
"category": "Twitch Highlighter"
},
{
"command": "twitchHighlighter.context.unhighlight",
"title": "Remove Highlight",
"category": "Twitch Highlighter"
}
],
"menus": {
"commandPalette": [
{
"command": "twitchHighlighter.refreshTreeView",
"when": "false"
},
{
"command": "twitchHighlighter.unhighlight",
"when": "false"
},
{
"command": "twitchHighlighter.gotoHighlight",
"when": "false"
},
{
"command": "twitchHighlighter.requestHighlight",
"when": "false"
},
{
"command": "twitchHighlighter.requestUnhighlight",
"when": "false"
},
{
"command": "twitchHighlighter.requestUnhighlightAll",
"when": "false"
},
{
"command": "twitchHighlighter.context.unhighlight",
"when": "false"
}
],
"view/title": [
{
"command": "twitchHighlighter.refreshTreeView",
"when": "view == twitchHighlighterTreeView || view == twitchHighlighterTreeView-explorer || view == twitchHighlighterTreeView-debug",
"group": "navigation"
}
],
"view/item/context": [
{
"command": "twitchHighlighter.unhighlight",
"when": "view == twitchHighlighterTreeView || view == twitchHighlighterTreeView-explorer || view == twitchHighlighterTreeView-debug",
"group": "edit"
}
],
"editor/context": [
{
"command": "twitchHighlighter.context.unhighlight",
"group": "1_modification",
"when": "editorHasHighlights"
}
],
"editor/title/context": [
{
"command": "twitchHighlighter.unhighlightAll",
"group": "3_open",
"when": "editorHasHighlights"
}
]
},
"viewsContainers": {
"activitybar": [
{
"id": "twitchHighlighter-explorer",
"icon": "resources/highlighterIcon.svg",
"title": "Twitch Highlighter"
}
]
},
"views": {
"explorer": [
{
"id": "twitchHighlighterTreeView-explorer",
"name": "Highlights"
}
],
"debug": [
{
"id": "twitchHighlighterTreeView-debug",
"name": "Highlights"
}
],
"twitchHighlighter-explorer": [
{
"id": "twitchHighlighterTreeView",
"name": "Highlights",
"when": "config.twitchHighlighter.showHighlightsInActivityBar"
}
]
},
"configuration": {
"type": "object",
"title": "Twitch Highlighter",
"properties": {
"twitchHighlighter.channels": {
"type": "string",
"default": "",
"description": "A comma separated list of channel name(s) to connect to on Twitch. Example: 'clarkio', Another Example: 'clarkio, parithon'"
},
"twitchHighlighter.nickname": {
"type": "string",
"default": "",
"description": "The username the bot should use when joining a Twitch channel."
},
"twitchHighlighter.highlightColor": {
"type": "string",
"default": "green",
"markdownDescription": "Background color of the decoration. Use rgba() and define transparent background colors to play well with other decorations. Example: green"
},
"twitchHighlighter.highlightFontColor": {
"type": "string",
"default": "white",
"markdownDescription": "Font color of the decoration. Use rgba() and define transparent background colors to play well with other decorations. Example: white"
},
"twitchHighlighter.highlightBorder": {
"type": "string",
"default": "2px solid white",
"description": "CSS styling property that will be applied to text enclosed by a decoration."
},
"twitchHighlighter.announceBot": {
"title": "Twitch Highlighter",
"type": "boolean",
"default": true,
"description": "Whether or not the bot should announce its joining or leaving the chat room"
},
"twitchHighlighter.joinMessage": {
"type": "string",
"default": "Twitch Highlighter in the house!",
"description": "The message the bot will say when joining a chat room"
},
"twitchHighlighter.leaveMessage": {
"type": "string",
"default": "Twitch Highlighter has left the building!",
"description": "The message the bot will say when leaving a chat room"
},
"twitchHighlighter.requiredBadges": {
"type": "array",
"default": [],
"markdownDescription": "A list of badges required to use the highlighter command. The use must have at least one of these badges to use the command. Leave blank for no requirement. Example: moderator, subscriber, vip.",
"items": {
"type": "string",
"pattern": "(admin|bits|broadcaster|global_mod|moderator|subscriber|staff|turbo|premium|follower)*",
"errorMessage": "Expected one of the following: admin, bits, broadcaster, global_admin, moderator, subscriber, staff, turbo, premium, follower"
}
},
"twitchHighlighter.unhighlightOnDisconnect": {
"type": "boolean",
"default": false,
"description": "Unhighlight all lines when disconnected from the chat service."
},
"twitchHighlighter.showHighlightsInActivityBar": {
"type": "boolean",
"default": false,
"description": "Show the Highlights icon in the activity bar to display the tree view."
},
"twitchHighlighter.usageTip": {
"type": "string",
"default": "💡 To use the !line command, use the following format: !line --or-- multiple lines: !line - --or-- with a comment: !line ",
"description": "A tip shared by the bot when a user chats: '!line'."
}
}
}
},
"scripts": {
"vscode:prepublish": "webpack --config webpack.config.js --mode production",
"compile": "copyfiles -au 1 ./src/**/*.htm out/ && tsc -p ./",
"watch": "copyfiles -au 1 ./src/**/*.htm out/ && tsc --watch -p ./",
"test": "npm run compile && node ./out/test/runTest.js"
},
"devDependencies": {
"@types/keytar": "^4.0.1",
"@types/mocha": "^2.2.42",
"@types/node": "^8.10.62",
"@types/request": "^2.48.3",
"@types/tmi.js": "^1.4.0",
"@types/uuid": "^3.4.5",
"@types/vscode": "^1.31.0",
"@vscode/test-electron": "^1.6.1",
"bufferutil": "^4.0.1",
"clean-webpack-plugin": "^3.0.0",
"copy-webpack-plugin": "^6.0.1",
"copyfiles": "^2.3.0",
"glob": "^8.0.3",
"mocha": "^4.1.0",
"mocha-junit-reporter": "^1.18.0",
"spec-xunit-file": "0.0.1-3",
"ts-loader": "^5.3.3",
"tslint": "^5.8.0",
"typescript": "^5.0.4",
"utf-8-validate": "^5.0.2",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11"
},
"dependencies": {
"request": "^2.88.0",
"tmi.js": "^1.4.2",
"url": "^0.11.1",
"uuid": "^3.3.3"
}
}
================================================
FILE: src/api.ts
================================================
export interface HighlighterAPI {
/**
* Call this function to request a new highlight within the open, active text document.
* @param service The name of the service making the request, for example: twitch.
* @param userName The user requesting the highlight.
* @param startLine The start line used to highlight.
* @param endLine The end line of the highlight (only if highlighting multiple lines).
* @param comments The comment to add to the highlight.
*/
requestHighlight(service: string, userName: string, startLine: number, endLine?: number, comments?: string): void;
/**
* Call this function to request a highlight to be removed from the open, active text document.
* @param service The name of the service making the request, for example: twitch.
* @param userName The user requesting the unhighlight.
* @param lineNumber A line number where the highlight exists.
*/
requestUnhighlight(service: string, userName: string, lineNumber: number): void;
/**
* Call this function to request that all the highlights requested from the service are removed.
* @param service The name of the service making the request, for example: twitch.
*/
requestUnhighlightAll(service: string): void;
}
================================================
FILE: src/app.ts
================================================
import * as vscode from 'vscode';
import { HighlighterAPI } from './api';
import { AppContexts, Commands, Configuration, LogLevel, Settings } from './enums';
import {
HighlightManager,
HighlightTreeDataProvider, HighlightTreeItem
} from './highlight';
import { log, Logger } from './logger';
export class App implements vscode.Disposable {
private readonly _highlightManager: HighlightManager;
private readonly _highlightTreeDataProvider: HighlightTreeDataProvider;
private log: log;
private highlightDecorationType: vscode.TextEditorDecorationType;
private currentDocument?: vscode.TextDocument;
private config?: vscode.WorkspaceConfiguration;
constructor(outputChannel?: vscode.OutputChannel) {
this.log = new Logger(outputChannel).log;
this.config = vscode.workspace.getConfiguration(Configuration.sectionIdentifier);
this.highlightDecorationType = this.createTextEditorDecorationType();
this._highlightManager = new HighlightManager();
this._highlightTreeDataProvider = new HighlightTreeDataProvider(this._highlightManager.GetHighlightCollection.bind(this._highlightManager));
}
public intialize(context: vscode.ExtensionContext) {
this.log('Initializing line highlighter...');
context.subscriptions.push(
this._highlightManager.onHighlightChanged(this.onHighlightChangedHandler, this),
// @ts-ignore
vscode.window.onDidChangeVisibleTextEditors(this.onDidChangeVisibleTextEditorsHandler, this),
vscode.window.onDidChangeActiveTextEditor(this.onDidChangeActiveTextEditorHandler, this),
vscode.workspace.onDidChangeTextDocument(this.onDidChangeTextDocumentHandler, this),
vscode.workspace.onDidChangeConfiguration(this.onDidChangeConfigurationHandler, this),
vscode.window.registerTreeDataProvider('twitchHighlighterTreeView-explorer', this._highlightTreeDataProvider),
vscode.window.registerTreeDataProvider('twitchHighlighterTreeView-debug', this._highlightTreeDataProvider),
vscode.window.registerTreeDataProvider('twitchHighlighterTreeView', this._highlightTreeDataProvider),
vscode.commands.registerCommand(Commands.refreshTreeView, this.refreshTreeviewHandler, this),
vscode.commands.registerCommand(Commands.highlight, this.highlightHandler, this),
vscode.commands.registerCommand(Commands.unhighlight, this.unhighlightHandler, this),
vscode.commands.registerCommand(Commands.unhighlightSpecific, this.unhighlightSpecificHandler, this),
vscode.commands.registerCommand(Commands.unhighlightAll, this.unhighlightAllHandler, this),
vscode.commands.registerCommand(Commands.gotoHighlight, this.gotoHighlightHandler, this),
vscode.commands.registerCommand(Commands.requestHighlight, this.requestHighlightHandler, this),
vscode.commands.registerCommand(Commands.requestUnhighlight, this.requestUnhighlightHandler, this),
vscode.commands.registerCommand(Commands.requestUnhighlightAll, this.requestUnhighlightAllHandler, this),
vscode.commands.registerCommand(Commands.contextMenuUnhighlight, this.contextMenuUnhighlightHandler, this)
);
this.log('Initialized line highlighter.');
}
public API: HighlighterAPI = {
requestHighlight(service: string, userName: string, startLine: number, endLine?: number, comments?: string) {
vscode.commands.executeCommand(Commands.requestHighlight,
service,
userName,
startLine,
endLine,
comments
);
},
requestUnhighlight(service: string, userName: string, lineNumber: number) {
vscode.commands.executeCommand(Commands.requestUnhighlight,
service,
userName,
lineNumber
);
},
requestUnhighlightAll(service: string) {
vscode.commands.executeCommand(Commands.requestUnhighlightAll, service);
}
};
public async dispose() {
}
private onDidChangeTextDocumentHandler(event: vscode.TextDocumentChangeEvent): void {
if (event.document.languageId === 'Log') {
return;
}
// Determine if the change occured on a highlighted line, if it did then adjust the highlight.
event.contentChanges.forEach(valueChanged => {
this._highlightManager.UpdateHighlight(event.document, valueChanged);
});
// Determine if we changed the fileName of the currently active open document.
if (this.currentDocument && event.document.fileName !== this.currentDocument.fileName) {
this._highlightManager.Rename(this.currentDocument.fileName, event.document.fileName);
this.currentDocument = event.document;
}
}
private onDidChangeConfigurationHandler(event: vscode.ConfigurationChangeEvent): void {
if (!event.affectsConfiguration(Configuration.sectionIdentifier)) {
return;
}
this.config = vscode.workspace.getConfiguration(Configuration.sectionIdentifier);
this.highlightDecorationType = this.createTextEditorDecorationType();
this.refresh();
}
private onDidChangeVisibleTextEditorsHandler(editors: Array): void {
if (editors.length > 0) {
editors.forEach(te => {
te.setDecorations(
this.highlightDecorationType,
this._highlightManager.GetDecorations(te.document.fileName)
);
});
}
}
/**
* Sets the 'editorHasHighlights' to true or false.
* The 'editorHasHighlights' context is used to determine if the
* 'Remove Highlight' and 'Remove All Highlights' context menu items
* are visible or not.
*/
private setEditorHasHighlightsContext() {
if (vscode.window.activeTextEditor) {
const editor = vscode.window.activeTextEditor;
if (this._highlightManager.GetDecorations(editor.document.fileName).length > 0) {
vscode.commands.executeCommand('setContext', AppContexts.editorHasHighlights, true);
} else {
vscode.commands.executeCommand('setContext', AppContexts.editorHasHighlights, false);
}
}
}
private onDidChangeActiveTextEditorHandler(editor?: vscode.TextEditor): void {
if (editor) {
this.currentDocument = editor.document;
}
else {
this.currentDocument = undefined;
}
this.setEditorHasHighlightsContext();
}
private refreshTreeviewHandler(): void {
this._highlightTreeDataProvider.refresh();
}
private createTextEditorDecorationType(): vscode.TextEditorDecorationType {
const configuration = vscode.workspace.getConfiguration(Configuration.sectionIdentifier);
if (this.highlightDecorationType) {
this.highlightDecorationType.dispose();
}
return vscode.window.createTextEditorDecorationType({
backgroundColor: configuration.get(Settings.highlightColor) || 'green',
border: configuration.get(Settings.highlightBorder) || '2px solid white',
color: configuration.get(Settings.highlightFontColor) || 'white'
});
}
private refresh(): void {
this.setEditorHasHighlightsContext();
vscode.window.visibleTextEditors.forEach(te => {
te.setDecorations(
this.highlightDecorationType,
this._highlightManager.GetDecorations(te.document.fileName)
);
});
this._highlightTreeDataProvider.refresh();
}
private onHighlightChangedHandler(): void {
this.refresh();
}
private get isActiveTextEditor(): boolean {
const editor = vscode.window.activeTextEditor;
return (editor !== undefined &&
editor.document.languageId !== 'log' &&
editor.document.getText().length > 0);
}
private async highlightHandler(): Promise {
const editor = vscode.window.activeTextEditor;
if (!this.isActiveTextEditor) {
vscode.window.showInformationMessage('The current open, and active text editor is either empty or not a valid target to highlight a line.');
return;
}
try {
const options: vscode.InputBoxOptions = {
ignoreFocusOut: true,
prompt: "Enter a line number"
};
const value = await vscode.window.showInputBox(options);
if (value) {
this._highlightManager.Add(editor!.document, 'self', +(value || 0));
}
}
catch (err) {
this.log(LogLevel.Error, err);
}
}
private async unhighlightHandler(treeItem?: HighlightTreeItem): Promise {
if (treeItem) {
const fileName = treeItem.fileName;
const highlightLines = treeItem.highlights.map(h => h.startLine);
highlightLines.forEach(line => {
this._highlightManager.Remove(fileName, 'self', line, true);
});
this._highlightManager.Refresh();
}
else {
try {
const options: vscode.QuickPickOptions = {
ignoreFocusOut: true
};
const value = await vscode.window.showQuickPick(this._highlightManager.GetHighlightDetails(), options);
if (value) {
const fileNameAndLineNumber = value.split(": ");
const fileName = fileNameAndLineNumber[0];
const lineNumber = fileNameAndLineNumber[1];
this._highlightManager.Remove(fileName, 'self', +(lineNumber));
}
}
catch (err) {
this.log(LogLevel.Error, err);
}
}
}
private async unhighlightSpecificHandler(): Promise {
if (this._highlightManager.GetHighlightCollection().length === 0) {
vscode.window.showInformationMessage(
'There are no highlights to unhighlight'
);
return;
}
let pickerOptions: Array = new Array();
const highlights = this._highlightManager.GetHighlightDetails();
highlights.forEach(highlight => {
pickerOptions = [...pickerOptions, highlight];
});
try {
const pickedOption = await vscode.window.showQuickPick(pickerOptions);
if (!pickedOption) {
this.log('A valid highlight was not selected.');
return;
}
const [pickedFile, lineNumber] = pickedOption.split(': ');
this._highlightManager.Remove(pickedFile, 'self', +(lineNumber));
}
catch (err) {
this.log(LogLevel.Error, err);
}
}
private unhighlightAllHandler(): void {
this._highlightManager.Clear();
}
private async gotoHighlightHandler(lineNumber: number, fileName: string): Promise {
const document = await vscode.workspace.openTextDocument(fileName);
if (document) {
vscode.window.showTextDocument(document).then(editor => {
lineNumber = lineNumber < 3 ? 2 : lineNumber;
editor.revealRange(document.lineAt(lineNumber - 2).range);
});
}
}
private requestHighlightHandler(service: string, userName: string, startLine: number, endLine?: number, comments?: string): void {
const editor = vscode.window.activeTextEditor;
if (!this.isActiveTextEditor) {
this.log(LogLevel.Warning, `Could not highlight the line requested by ${service}:${userName}`);
this.log(LogLevel.Warning, 'The current open, and active text editor is either empty or not a valid target to highlight a line.');
return;
}
this._highlightManager.Add(editor!.document, `${service}:${userName}`, startLine, endLine || startLine, comments);
}
private requestUnhighlightHandler(service: string, userName: string, lineNumber: number): void {
const editor = vscode.window.activeTextEditor;
if (!this.isActiveTextEditor) {
this.log(LogLevel.Warning, `Could not unhighlight the line requested by ${service}:${userName}`);
this.log(LogLevel.Warning, 'The current open, and active text editor is either empty or not a valid target to highlight a line.');
return;
}
this._highlightManager.Remove(editor!.document, `${service}:${userName}`, lineNumber);
}
private requestUnhighlightAllHandler(service: string): void {
this._highlightManager.Clear(service);
}
private contextMenuUnhighlightHandler() {
if (vscode.window.activeTextEditor) {
const lineNumber = vscode.window.activeTextEditor.selection.active.line;
this._highlightManager.Remove(vscode.window.activeTextEditor.document, 'self', lineNumber + 1);
}
}
}
================================================
FILE: src/common/index.ts
================================================
export * from './keytar';
================================================
FILE: src/common/keytar.ts
================================================
import * as keytartype from 'keytar';
import { getNodeModule } from '../utils';
export const keytar: typeof keytartype | undefined = getNodeModule('keytar');
================================================
FILE: src/constants.ts
================================================
export const extensionId = 'clarkio.twitch-highlighter';
export const extSuffix = 'twitchHighlighter';
================================================
FILE: src/credentialManager.ts
================================================
import * as keytartype from 'keytar';
import { env } from 'vscode';
declare const __webpack_require__: typeof require;
declare const __non_webpack_require__: typeof require;
function getNodeModule(moduleName: string): T | undefined {
const r = typeof __webpack_require__ === 'function' ? __non_webpack_require__ : require;
try {
return r(`${env.appRoot}/node_modules.asar/${moduleName}`);
}
catch (err) {
// Not in ASAR.
}
try {
return r(`${env.appRoot}/node_modules/${moduleName}`);
}
catch (err) {
// Not available
}
return undefined;
}
export class CredentialManager {
private static service: string = "vscode-twitch-highlighter";
private static clientIdIdentifier: string = "twitchClientId";
private static passwordIdentifier: string = "twitchToken";
private static keytar: typeof keytartype | undefined = getNodeModule('keytar');
/**
* @deprecated Included only so people can remove their previous Client ID.
*/
public static async deleteTwitchClientId(): Promise {
if (CredentialManager.keytar) {
return await CredentialManager.keytar.deletePassword(CredentialManager.service, CredentialManager.clientIdIdentifier);
}
return false;
}
public static async setPassword(value: string): Promise {
if (CredentialManager.keytar && value !== null) {
await CredentialManager.keytar.setPassword(CredentialManager.service, CredentialManager.passwordIdentifier, value);
}
}
public static async deleteTwitchToken(): Promise {
if (CredentialManager.keytar) {
return await CredentialManager.keytar.deletePassword(CredentialManager.service, CredentialManager.passwordIdentifier);
}
return false;
}
public static getTwitchToken(): Promise {
return new Promise(async resolve => {
const password = await CredentialManager.getPassword(CredentialManager.passwordIdentifier);
resolve(password);
});
}
private static async getPassword(account: string): Promise {
if (CredentialManager.keytar) {
return await CredentialManager.keytar.getPassword(CredentialManager.service, account);
}
return null;
}
}
export default CredentialManager;
================================================
FILE: src/enums/AppContexts.ts
================================================
export enum AppContexts {
'editorHasHighlights' = 'editorHasHighlights'
}
================================================
FILE: src/enums/Commands.ts
================================================
// export enum Commands {
// 'highlight' = 'twitchHighlighter.highlight',
// 'unhighlight' = 'twitchHighlighter.unhighlight',
// 'unhighlightAll' = 'twitchHighlighter.unhighlightAll',
// 'unhighlightSpecific' = 'twitchHighlighter.unhighlightSpecific',
// 'startChat' = 'twitchHighlighter.startChat',
// 'stopChat' = 'twitchHighlighter.stopChat',
// 'toggleChat' = 'twitchHighlighter.toggleChat',
// 'removeTwitchClientId' = 'twitchHighlighter.removeTwitchClientId',
// 'setTwitchToken' = 'twitchHighlighter.setTwitchToken',
// 'removeTwitchToken' = 'twitchHighlighter.removeTwitchToken',
// 'refreshTreeView' = 'twitchHighlighter.refreshTreeView',
// 'gotoHighlight' = 'twitchHighlighter.gotoHighlight',
// 'removeHighlight' = 'twitchHighlighter.removeHighlight',
// 'requestHighlight' = 'twitchHighlighter.requestHighlight',
// 'requestUnhighlight' = 'twitchHighlighter.requestUnhighlight',
// 'requestUnhighlightAll' = 'twitchHighlighter.requestUnhighlightAll',
// 'signIn' = 'twitchHighlighter.signIn',
// 'signOut' = 'twitchHighlighter.signOut',
// 'connect' = 'twitchHighlighter.connect',
// 'disconnect' = 'twitchHighlighter.disconnect'
// }
export enum Commands {
'refreshTreeView' = 'twitchHighlighter.refreshTreeView',
'highlight' = 'twitchHighlighter.highlight',
'unhighlight' = 'twitchHighlighter.unhighlight',
'unhighlightSpecific' = 'twitchHighlighter.unhighlightSpecific',
'unhighlightAll' = 'twitchHighlighter.unhighlightAll',
'gotoHighlight' = 'twitchHighlighter.gotoHighlight',
'requestHighlight' = 'twitchHighlighter.requestHighlight',
'requestUnhighlight' = 'twitchHighlighter.requestUnhighlight',
'requestUnhighlightAll' = 'twitchHighlighter.requestUnhighlightAll',
'signIn' = 'twitchHighlighter.signIn',
'signOut' = 'twitchHighlighter.signOut',
'connect' = 'twitchHighlighter.connect',
'disconnect' = 'twitchHighlighter.disconnect',
'contextMenuUnhighlight' = 'twitchHighlighter.context.unhighlight'
}
================================================
FILE: src/enums/Configuration.ts
================================================
export enum Configuration {
"sectionIdentifier" = "twitchHighlighter",
"highlightBackgroundColor" = "highlightBackgroundColor",
"highlightForegroundColor" = "highlightForegroundColor",
"highlightBorderStyle" = "highlightBorderStyle",
"showActivityBar" = "showActivityBar"
}
================================================
FILE: src/enums/InternalCommands.ts
================================================
export enum InternalCommands {
'removeBannedHighlights' = 'twitchHighlighter.removeBannedHighlights'
}
================================================
FILE: src/enums/KeytarKeys.ts
================================================
export enum KeytarKeys {
"service" = "vscode-twitch-highlighter-ttvchat",
"account" = "vscode-twitch-highlighter-ttvchat.account",
"userId" = "vscode-twitch-highlighter-ttvchat.userId",
"userLogin" = "vscode-twitch-highlighter-ttvchat.userLogin"
}
================================================
FILE: src/enums/LogLevel.ts
================================================
export enum LogLevel {
'Information' = 'info',
'Warning' = 'warn',
'Error' = 'error',
'Debug' = 'debug'
}
================================================
FILE: src/enums/Settings.ts
================================================
export enum Settings {
'channels' = 'channels',
'username' = 'nickname',
'highlightColor' = 'highlightColor',
'highlightBorder' = 'highlightBorder',
'highlightFontColor' = 'highlightFontColor',
'announceBot' = 'announceBot',
'joinMessage' = 'joinMessage',
'leaveMessage' = 'leaveMessage',
'unhighlightOnDisconnect' = 'unhighlightOnDisconnect',
'showHighlightsInActivityBar' = 'showHighlightsInActivityBar',
'usageTip' = 'usageTip',
'requiredBadges' = 'requiredBadges'
}
================================================
FILE: src/enums/TwitchKeys.ts
================================================
export enum TwitchKeys {
"clientId" = "83juwb58ggj9s7l7nf9ngyill70tem",
"scope" = "chat:read chat:edit",
}
================================================
FILE: src/enums/index.ts
================================================
export * from './Commands';
export * from './InternalCommands';
export * from './Settings';
export * from './LogLevel';
export * from './Configuration';
export * from './KeytarKeys';
export * from './TwitchKeys';
export * from './AppContexts';
================================================
FILE: src/extension.ts
================================================
'use strict';
// The module 'vscode' contains the VS Code extensibility API
// Import the module and reference it with the alias vscode in your code below
import * as vscode from 'vscode';
import { CredentialManager } from './credentialManager';
import { App } from './app';
import { TwitchChatService } from './ttvchat';
let app: App;
let ttvchat: TwitchChatService;
// this method is called when your extension is activated
// your extension is activated the very first time the command is executed
export function activate(context: vscode.ExtensionContext) {
// Remove the older credentials if they exist.
CredentialManager.deleteTwitchToken();
CredentialManager.deleteTwitchClientId();
const outputChannel = vscode.window.createOutputChannel('Twitch Line Highlighter');
app = new App(outputChannel);
ttvchat = new TwitchChatService(app.API, outputChannel);
app.intialize(context);
ttvchat.initialize(context);
return app.API;
}
export function deactivate() {
ttvchat.dispose();
}
export const editorHasDecorations = () => {
return true;
}
================================================
FILE: src/highlight/Highlight.ts
================================================
import { Range } from 'vscode';
export class Highlight {
private _userName: string;
private _range: Range;
private _comments?: string;
constructor(userName: string, range: Range, comments?: string) {
this._userName = userName;
this._range = range;
this._comments = comments;
}
public get range(): Range {
return this._range;
}
public get userName(): string {
return this._userName;
}
// The textEditor start line is zero indexed.
// We adjust to match the editors line numbers as shown to the user.
public get startLine(): number {
return this._range.start.line + 1;
}
// The textEditor end line is zero indexed.
// We adjust to match the editors line numbers as shown to the user.
public get endLine(): number {
return this._range.end.line + 1;
}
public get comments(): string | undefined {
return this._comments;
}
// The range should only be updated by the app
// when changes occur in the TextEditor, such as a newline
// is added above this highlight range.
public Update(newRange: Range): void {
this._range = newRange;
}
}
================================================
FILE: src/highlight/HighlightManager.ts
================================================
import {
TextDocument,
Range,
Position,
EventEmitter,
Event,
TextDocumentContentChangeEvent,
DecorationOptions
} from "vscode";
import { isString } from 'util';
import { Highlight } from './Highlight';
export interface HighlightCollection {
fileName: string;
highlights: Array;
}
export interface HighlightChangedEvent {
}
export class HighlightManager {
private readonly _onHighlightsChanged: EventEmitter = new EventEmitter();
private highlightCollection: Array = [];
public get onHighlightChanged(): Event {
return this._onHighlightsChanged.event;
}
public GetHighlightCollection(): Array {
return this.highlightCollection;
}
public GetHighlightDetails(): string[] {
if (this.highlightCollection.length > 0) {
return this.highlightCollection
.map(hc => hc.highlights.map(h => `${hc.fileName}: ${h.startLine}`))
.reduce(s => s)
.sort((hA, hB) => hB.localeCompare(hA));
}
return [];
}
public GetDecorations(fileName: string): DecorationOptions[] {
const idx = this.highlightCollection.findIndex(hc => hc.fileName === fileName);
if (idx > -1) {
return this.highlightCollection[idx].highlights.map(h => {
return {
hoverMessage: `From ${h.userName === 'self'? 'You' : h.userName} ${h.comments !== undefined ? h.comments : ''}`,
range: h.range
};
});
}
return [];
}
public Add(document: TextDocument, userName: string, startLine: number, endLine?: number, comments?: string): Promise {
return new Promise(resolve => {
if (!endLine) {
endLine = startLine;
}
const range = new Range(
new Position(--startLine, 0),
new Position(--endLine, document.lineAt(endLine).text.length)
);
const highlight = new Highlight(userName, range, comments);
const idx = this.highlightCollection.findIndex(h => h.fileName === document.fileName);
if (idx > -1) {
if (!this.highlightCollection[idx].highlights.some(h => (h.userName === userName || userName === 'self') && h.startLine <= startLine && h.endLine >= endLine!)) {
this.highlightCollection[idx].highlights.push(highlight);
}
}
else {
this.highlightCollection.push({
fileName: document.fileName,
highlights: [highlight]
});
}
// @ts-ignore
this._onHighlightsChanged.fire();
resolve();
});
}
public Remove(document: TextDocument, userName: string, lineNumber: number, deferRefresh?: boolean): Promise;
public Remove(fileName: string, userName: string, lineNumber: number, deferRefresh?: boolean): Promise;
public Remove(documentOrFileName: TextDocument | string, userName: string, lineNumber: number, deferRefresh: boolean = false): Promise {
return new Promise(resolve =>{
if (!isString(documentOrFileName)) {
documentOrFileName = documentOrFileName.fileName;
}
const idx = this.highlightCollection.findIndex(h => h.fileName === documentOrFileName);
if (idx > -1) {
const hidx = this.highlightCollection[idx].highlights.findIndex(h => (h.userName === userName || userName === 'self') && h.startLine <= lineNumber && h.endLine >= lineNumber);
if (hidx > -1) {
this.highlightCollection[idx].highlights.splice(hidx, 1);
}
if (!deferRefresh) {
// @ts-ignore
this._onHighlightsChanged.fire();
}
}
resolve();
});
}
public Refresh() {
// @ts-ignore
this._onHighlightsChanged.fire();
}
public Clear(service?: string): Promise {
return new Promise(resolve =>{
if (service) {
this.highlightCollection.forEach(hc => {
const highlightsToRemove = hc.highlights.filter(h => h.userName.indexOf(`${service}:`) > -1);
highlightsToRemove.forEach(h => {
this.Remove(hc.fileName, h.userName, h.startLine, true);
});
});
}
else {
this.highlightCollection = new Array();
}
// @ts-ignore
this._onHighlightsChanged.fire();
});
}
public Rename(oldName: string, newName: string) {
const idx = this.highlightCollection.findIndex(hc => hc.fileName === oldName);
if (idx > -1) {
this.highlightCollection[idx].fileName = newName;
}
}
public UpdateHighlight(document: TextDocument, valueChanged: TextDocumentContentChangeEvent) {
const idx = this.highlightCollection.findIndex(hc => hc.fileName === document.fileName);
let updated = false;
if (idx > -1) {
// A carriage return was removed.
if (valueChanged.text.length === 0 && valueChanged.range.end.line === valueChanged.range.start.line + 1) {
let highlights = this.highlightCollection[idx].highlights.filter(h => h.range.start.line > valueChanged.range.end.line);
highlights.forEach(highlight => {
highlight.Update(new Range(
new Position(highlight.range.start.line - 1, highlight.range.start.character),
new Position(highlight.range.end.line, highlight.range.end.character)
));
updated = true;
});
highlights = this.highlightCollection[idx].highlights.filter(h => h.range.end.line >= valueChanged.range.end.line);
highlights.forEach(highlight => {
highlight.Update(new Range(
new Position(highlight.range.start.line, highlight.range.start.character),
new Position(highlight.range.end.line - 1, highlight.range.end.character)
));
updated = true;
});
}
else if (valueChanged.text.match('\n')) {
let highlights = this.highlightCollection[idx].highlights.filter(h => h.range.end.line >= valueChanged.range.start.line);
highlights.forEach(highlight => {
highlight.Update(new Range(
new Position(highlight.range.start.line, highlight.range.start.character),
new Position(highlight.range.end.line + 1, highlight.range.end.character)
));
updated = true;
});
highlights = this.highlightCollection[idx].highlights.filter(h => h.range.start.line > valueChanged.range.start.line);
highlights.forEach(highlight => {
highlight.Update(new Range(
new Position(highlight.range.start.line + 1, highlight.range.start.character),
new Position(highlight.range.end.line, highlight.range.end.character)
));
updated = true;
});
}
else {
const highlights = this.highlightCollection[idx].highlights.filter(h => h.range.contains(valueChanged.range));
highlights.forEach(h => {
if (valueChanged.text.length === 0) { // A character was deleted.
h.Update(new Range(
new Position(h.range.start.line, h.range.start.character),
new Position(h.range.end.line, h.range.end.character - 1)
));
updated = true;
}
else {
h.Update(new Range(
new Position(h.range.start.line, h.range.start.character),
new Position(h.range.end.line, h.range.end.character + valueChanged.text.length)
));
updated = true;
}
});
}
}
if (updated) {
// @ts-ignore
this._onHighlightsChanged.fire();
}
}
}
================================================
FILE: src/highlight/index.ts
================================================
export * from './Highlight';
export * from './HighlightManager';
export * from './treeView';
================================================
FILE: src/highlight/treeView/HighlightTreeDataProvider.ts
================================================
import {
TreeDataProvider,
EventEmitter,
Event,
TreeItem,
TreeItemCollapsibleState
} from "vscode";
import{ basename } from 'path';
import { HighlightTreeItem } from "./HighlightTreeItem";
import { HighlightCollection } from "../HighlightManager";
import { naturalCompare } from '../../utils';
export class HighlightTreeDataProvider implements TreeDataProvider {
private readonly _onDidChangeTreeData: EventEmitter;
constructor(private getHighlightCollections = (): HighlightCollection[] => []) {
this._onDidChangeTreeData = new EventEmitter();
}
public get onDidChangeTreeData(): Event {
return this._onDidChangeTreeData.event;
}
public refresh(): void {
// @ts-ignore
this._onDidChangeTreeData.fire();
}
public getTreeItem(element: HighlightTreeItem): TreeItem {
return element;
}
public getChildren(element?: HighlightTreeItem): Thenable {
if (element) {
return Promise.resolve(element.HighlightTreeItems.sort((highlightA, highlightB) => naturalCompare(highlightA.label, highlightB.label)));
}
let highlightTreeItems = new Array();
const currentHighlightCollections = this.getHighlightCollections().filter(hc => hc.highlights.length > 0);
currentHighlightCollections.forEach(hc => {
const highlights = hc.highlights;
const label = basename(hc.fileName);
highlightTreeItems.push(new HighlightTreeItem(label, hc.fileName, highlights, TreeItemCollapsibleState.Expanded));
});
highlightTreeItems = highlightTreeItems
.sort((highlightTreeItemA, highlightTreeItemB) => highlightTreeItemB.label.localeCompare(highlightTreeItemA.label));
return Promise.resolve(highlightTreeItems.sort((highlightTreeItemA, highlightTreeItemB) => naturalCompare(highlightTreeItemA.label, highlightTreeItemB.label)));
}
}
================================================
FILE: src/highlight/treeView/HighlightTreeItem.ts
================================================
import { TreeItem, TreeItemCollapsibleState, Command } from "vscode";
import { Highlight } from "../Highlight";
import { Commands } from "../../enums";
export class HighlightTreeItem extends TreeItem {
constructor(
public readonly label: string,
public readonly fileName: string,
public readonly highlights: Highlight[] = [],
public readonly collapsibleState: TreeItemCollapsibleState,
public readonly command?: Command
) {
super(label, collapsibleState);
}
// @ts-ignore
public get description(): string {
if (this.highlights.length > 0) {
return `Highlights: ${this.highlights.length}`;
}
return '';
}
public get HighlightTreeItems(): HighlightTreeItem[] {
const children = new Array();
this.highlights.forEach(highlight => {
const label = `Line: ${highlight.endLine > highlight.startLine ? `${highlight.startLine} - ${highlight.endLine}` : `${highlight.startLine}`}`;
const existingItem = children.find(item => item.label === label);
if (existingItem) {
existingItem.highlights.push(highlight);
}
else {
const command: Command = {
command: Commands.gotoHighlight,
title: '',
arguments: [highlight.startLine, this.fileName]
};
children.push(new HighlightTreeItem(label, this.fileName, [highlight], TreeItemCollapsibleState.None, command));
}
});
return children;
}
contextValue = 'highlightTreeItem';
}
================================================
FILE: src/highlight/treeView/index.ts
================================================
export * from './HighlightTreeItem';
export * from './HighlightTreeDataProvider';
================================================
FILE: src/index.ts
================================================
export * from './api';
================================================
FILE: src/logger.ts
================================================
import { OutputChannel } from 'vscode';
import { LogLevel } from './enums';
import { isEnum } from './utils';
export type log = {
(message: string, ...optionalParams: any[]): void;
(level: LogLevel, message?: string, ...optionalParams: any[]): void;
};
export class Logger {
private readonly _channel?: OutputChannel;
constructor(outputChannel?: OutputChannel, thisArgs?: any) {
this._channel = outputChannel;
this.log = this.log.bind(thisArgs || this);
}
public log(message: string, ...optionalParams: any[]): void;
public log(levelOrMessage: LogLevel | string, message?: string, ...optionalParams: any[]): void {
const captains: any = console;
let level;
if (isEnum(levelOrMessage, LogLevel)) {
level = levelOrMessage;
}
else {
level = LogLevel.Information;
message = levelOrMessage;
}
const getTime = (): {
hours: string,
minutes: string,
seconds: string
} => {
const date = new Date();
const hours = date.getHours();
const minutes = date.getMinutes();
const seconds = date.getSeconds();
const prefix = (value: number): string => {
return value < 10 ? `0${value}` : `${value}`;
};
return {
hours: prefix(hours),
minutes: prefix(minutes),
seconds: prefix(seconds)
};
};
const { hours, minutes, seconds } = getTime();
const log = `[${hours}:${minutes}:${seconds}] ${message}`;
captains[level](log, ...optionalParams);
if (this._channel && level !== LogLevel.Debug) {
this._channel.appendLine(log);
}
}
}
================================================
FILE: src/test/runTest.ts
================================================
import * as path from "path";
import { runTests } from "@vscode/test-electron";
async function main() {
try {
// The folder containing the Extension Manifest package.json
// Passed to `--extensionDevelopmentPath`
const extensionDevelopmentPath = path.resolve(__dirname, "../../");
// The path to the extension test runner script
// Passed to --extensionTestsPath
const extensionTestsPath = path.resolve(__dirname, "./suite/index");
// Download VS Code, unzip it and run the integration test
await runTests({ extensionDevelopmentPath, extensionTestsPath });
} catch (err) {
console.error(err);
console.error("Failed to run tests");
process.exit(1);
}
}
main();
================================================
FILE: src/test/suite/extension.test.ts
================================================
import { extensionId, extSuffix } from '../../constants';
import { Settings } from "../../enums";
import { Commands } from "../../enums";
//
// Note: This example test is leveraging the Mocha test framework.
// Please refer to their documentation on https://mochajs.org/ for help.
//
// The module 'assert' provides assertion methods from node
import * as assert from 'assert';
// You can import and use all API from the 'vscode' module
// as well as import your extension to test it
import * as vscode from 'vscode';
// import * as myExtension from '../extension';
interface ICommand {
title: string;
command: string;
category: string;
}
interface IConfiguration {
type: string;
title: string;
properties: any;
}
export type EnumIndexer = {
[Key: string]: string
};
// Defines a Mocha test suite to group tests of similar kind together
suite("Extension Tests", function () {
let extension: vscode.Extension;
setup(function () {
const ext = vscode.extensions.getExtension(extensionId);
if (!ext) {
throw new Error('Extension was not found.');
}
if (ext) { extension = ext; }
});
/**
* Because we are waiting on a process to complete in the background
* we use the `done` function to inform mocha that this test run is
* complete.
*/
test("Extension loads in VSCode and is active", function (done) {
// Hopefully a 200ms timeout will allow the extension to activate within Windows
// otherwise we get a false result.
setTimeout(function () {
assert.equal(extension.isActive, true);
done();
}, 200);
});
test("constants.Commands exist in package.json", function () {
const commandCollection: ICommand[] = extension.packageJSON.contributes.commands;
for (let command in Commands) {
const result = commandCollection.some(c => c.command === (Commands as any)[command]);
assert.ok(result);
}
});
test("constants.Settings exist in package.json", function () {
const config: IConfiguration = extension.packageJSON.contributes.configuration;
const properties = Object.keys(config.properties);
for (let setting in Settings) {
const result = properties.some(property => property === `${extSuffix}.${(Settings as any)[setting]}`);
assert.ok(result);
}
});
test('package.json commands registered in extension', function (done) {
const commandStrings: string[] = extension.packageJSON.contributes.commands.map((c: ICommand) => c.command);
vscode.commands.getCommands(true)
.then((allCommands: string[]) => {
const commands: string[] = allCommands.filter(c => c.startsWith(`${extSuffix}.`));
commands.forEach(command => {
const result = commandStrings.some(c => c === command);
assert.ok(result);
});
done();
});
});
});
================================================
FILE: src/test/suite/index.ts
================================================
import * as path from "path";
import * as Mocha from "mocha";
import * as glob from "glob";
export function run(): Promise {
// Create the mocha test
const mocha = new Mocha({
ui: "tdd",
});
mocha.useColors(true);
const testsRoot = path.resolve(__dirname, "..");
return new Promise((c, e) => {
glob("**/**.test.js", { cwd: testsRoot }, (err, files) => {
if (err) {
return e(err);
}
// Add files to the test suite
files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f)));
try {
// Run the mocha test
mocha.run((failures) => {
if (failures > 0) {
e(new Error(`${failures} tests failed.`));
} else {
c();
}
});
} catch (err) {
console.error(err);
e(err);
}
});
});
}
================================================
FILE: src/test/suite/utils.test.ts
================================================
import * as assert from 'assert';
import { parseMessage } from '../../utils';
interface Theory {
message: string;
startLine: number;
endLine: number;
fileName?: string;
comment?: string;
}
suite('Utils Tests', function() {
test('Ensure parseMessage returns expected results', () => {
const theories: Theory[] = [
{
message: '!line 5',
startLine: 5,
endLine: 5
},
{
message: '!line settings.js 5',
startLine: 5,
endLine: 5,
fileName: 'settings.js'
},
{
message: '!line settings 5',
startLine: 5,
endLine: 5,
fileName: 'settings'
},
{
message: '!line 5 settings.js',
startLine: 5,
endLine: 5,
fileName: 'settings.js'
},
{
message: '!line 5 settings',
startLine: 5,
endLine: 5,
comment: 'settings'
},
{
message: '!line 5-15',
startLine: 5,
endLine: 15
},
{
message: '!line 5-15 comment',
startLine: 5,
endLine: 15,
comment: 'comment'
},
{
message: '!line settings.js 5-15 comment',
startLine: 5,
endLine: 15,
fileName: 'settings.js',
comment: 'comment'
},
{
message: '!line 5-15 settings.js comment',
startLine: 5,
endLine: 15,
fileName: 'settings.js',
comment: 'comment'
}
];
theories.forEach(({message, startLine, endLine, fileName, comment}) => {
const result = parseMessage(message);
assert.ok(result);
if (result) {
assert.equal(result.startLine, startLine);
assert.equal(result.endLine, endLine);
assert.equal(result.fileName, fileName);
assert.equal(result.comments, comment);
}
});
});
});
================================================
FILE: src/ttvchat/AuthenticationService.ts
================================================
import { readFileSync } from 'fs';
import * as http from 'http';
import * as path from 'path';
import * as url from 'url';
import { v4 } from 'uuid';
import { env, Event, EventEmitter, extensions, Uri, window } from 'vscode';
import { keytar } from '../common';
import { extensionId } from '../constants';
import { KeytarKeys, LogLevel, TwitchKeys } from '../enums';
import { log } from '../logger';
import { API } from './api';
export class AuthenticationService {
private readonly _onAuthStatusChanged: EventEmitter = new EventEmitter();
public readonly onAuthStatusChanged: Event = this._onAuthStatusChanged.event;
private port: number = 5001;
constructor(private log: log) { }
public async initialize() {
if (keytar) {
const accessToken = await keytar.getPassword(KeytarKeys.service, KeytarKeys.account);
const userLogin = await keytar.getPassword(KeytarKeys.service, KeytarKeys.userLogin);
if (accessToken && userLogin) {
// Twitch access tokens should be validated on a recurring interval.
await this.validateToken(accessToken);
return;
}
}
this._onAuthStatusChanged.fire(false);
}
// https://dev.twitch.tv/docs/authentication#validating-requests
public async validateToken(accessToken: string) {
await API.validateToken(accessToken);
this._onAuthStatusChanged.fire(true);
this.log("Twitch access token has been validated.");
const hour = 1000 * 60 * 60;
setInterval(this.validateToken, hour, accessToken); // Validate the token each hour
}
public async signInHandler() {
if (keytar) {
const accessToken = await keytar.getPassword(KeytarKeys.service, KeytarKeys.account);
if (!accessToken) {
const state = v4();
this.createServer(state);
env.openExternal(Uri.parse(`https://id.twitch.tv/oauth2/authorize?client_id=${TwitchKeys.clientId}` +
`&redirect_uri=http://localhost:${this.port}` +
`&response_type=token&scope=${TwitchKeys.scope}` +
`&force_verify=true` +
`&state=${state}`));
}
else {
const validResult = await API.validateToken(accessToken);
if (validResult.valid) {
this._onAuthStatusChanged.fire(true);
}
}
}
}
public async signOutHandler() {
if (keytar) {
const token = await keytar.getPassword(KeytarKeys.service, KeytarKeys.account);
if (token) {
const revoked = await API.revokeToken(token);
if (revoked) {
window.showInformationMessage('Twitch token revoked successfully');
}
}
keytar.deletePassword(KeytarKeys.service, KeytarKeys.account);
keytar.deletePassword(KeytarKeys.service, KeytarKeys.userLogin);
}
this._onAuthStatusChanged.fire(false);
}
private createServer(state: string) {
const filePath = path.join(extensions.getExtension(extensionId)!.extensionPath, 'out', 'ttvchat', 'login', 'index.htm');
this.log(LogLevel.Debug, `Starting login server using filePath: ${filePath}.`);
const file = readFileSync(filePath);
if (file) {
const server = http.createServer(async (req: any, res: any) => {
const mReq = url.parse(req.url!, true);
const mReqPath = mReq.pathname;
if (mReqPath === '/') {
res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache' });
res.end(file);
}
else if (mReqPath === '/oauth') {
const q: any = mReq.query;
if (q.state !== state) {
window.showErrorMessage('Error while logging in. State mismatch error.');
await API.revokeToken(q.access_token);
this._onAuthStatusChanged.fire(false);
res.writeHead(500, 'Error while logging in. State mismatch error.');
res.end();
return;
}
const validationResult = await API.validateToken(q.access_token);
if (keytar && validationResult.valid) {
keytar.setPassword(KeytarKeys.service, KeytarKeys.account, q.access_token);
keytar.setPassword(KeytarKeys.service, KeytarKeys.userLogin, validationResult.login);
this._onAuthStatusChanged.fire(true);
}
res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache' });
res.end(file);
}
else if (mReqPath === '/complete') {
res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache' });
res.end(file);
setTimeout(() => server.close(), 3000);
}
else if (mReqPath === '/favicon.ico') {
res.writeHead(204);
res.end();
}
});
server.listen(this.port, (err: any) => {
this.log(LogLevel.Error, err);
});
}
}
}
================================================
FILE: src/ttvchat/ChatClient.ts
================================================
import {
Badges, ChatUserstate, Client,
Options
} from "tmi.js";
import {
ConfigurationChangeEvent, Disposable, Event, EventEmitter,
ExtensionContext, workspace,
WorkspaceConfiguration
} from 'vscode';
import { keytar } from "../common";
import { Configuration, KeytarKeys, LogLevel, Settings } from "../enums";
import { log } from '../logger';
import { API } from './api';
interface IBadges extends Badges {
[key: string]: string | undefined;
follower: string;
}
export interface ChatClientMessageReceivedEvent {
userState: ChatUserstate;
message: string;
}
export class ChatClient implements Disposable {
private readonly _onChatClientConnected: EventEmitter = new EventEmitter();
private readonly _onChatClientMessageReceived: EventEmitter = new EventEmitter();
public readonly onChatClientConnected: Event = this._onChatClientConnected.event;
public readonly onChatClientMessageReceived: Event = this._onChatClientMessageReceived.event;
private config?: WorkspaceConfiguration;
private client?: Client;
private channel: string = "";
private announceBot: boolean = true;
private joinMessage: string = "";
private leaveMessage: string = "";
private requiredBadges: string[] = [];
constructor(private log: log) { }
public initialize(context: ExtensionContext) {
this.config = workspace.getConfiguration(Configuration.sectionIdentifier);
this.announceBot = this.config.get(Settings.announceBot) || true;
this.joinMessage = this.config.get(Settings.joinMessage) || "";
this.leaveMessage = this.config.get(Settings.leaveMessage) || "";
this.requiredBadges = this.config.get(Settings.requiredBadges) || [];
context.subscriptions.push(workspace.onDidChangeConfiguration(this.onDidChangeConfigurationHandler, this));
}
public async connect() {
if (keytar && this.config && !this.isConnected) {
const accessToken = await keytar.getPassword(KeytarKeys.service, KeytarKeys.account);
const login = await keytar.getPassword(KeytarKeys.service, KeytarKeys.userLogin);
if (accessToken && login) {
this.channel = this.config.get(Settings.channels) || login;
const opts: Options = {
identity: {
username: login,
password: accessToken
},
channels: this.channel.split(', ').map(c => c.trim())
};
this.client = Client(opts);
this.client.on('connected', this.onConnectedHandler.bind(this));
this.client.on('message', this.onMessageHandler.bind(this));
this.client.on('join', this.onJoinHandler.bind(this));
const status = await this.client.connect();
this._onChatClientConnected.fire(true);
return status;
}
}
}
public async disconnect() {
if (this.isConnected) {
if (this.announceBot && this.leaveMessage.length > 0) {
await this.sendMessage(this.leaveMessage);
}
if (this.client) {
await this.client.disconnect();
this.client = undefined;
}
this._onChatClientConnected.fire(false);
}
}
public async dispose() {
await this.disconnect();
}
private get isConnected(): boolean {
return this.client ? this.client.readyState() === 'OPEN' : false;
}
private async sendMessage(message: string) {
if (this.isConnected && this.client) {
await this.client.say(this.channel, message);
}
}
private async onJoinHandler(channel: string, username: string, self: boolean) {
if (self && this.client && this.announceBot && this.joinMessage.length > 0) {
this.log(`Joined channel: ${channel} as ${username}`);
await this.sendMessage(this.joinMessage);
}
}
private onConnectedHandler(address: string, port: number) {
this.log(`Connected chat client to ${address} port ${port}`);
}
private async onMessageHandler(channel: string, userState: ChatUserstate, message: string, self: boolean) {
this.log(`Received '${message}' from ${userState["display-name"]}`);
if (self) {
return;
}
if (!message) {
return;
}
const badges = userState.badges as IBadges || {};
badges.follower = await API.isUserFollowingChannel(userState.id!, channel) === true ? '1' : '0';
if (this.requiredBadges.length > 0 && !badges.broadcaster) {
// Check to ensure the user has a required badge
const canContinue = this.requiredBadges.some(badge => badges[badge] === '1');
// Bail if the user does not have the required badge
if (!canContinue) {
this.log(LogLevel.Warning, `${userState.username} does not have any of the required badges to use the highlight command.`);
return;
}
}
message = message.toLocaleLowerCase().trim();
if (message.startsWith('!line') || message.startsWith('!highlight')) {
// message = message.replace('!line', '').replace('!highlight', '').trim();
if (message.length === 0) {
this.sendMessage('💡 To use the !line command, use the following format: !line --or-- multiple lines: !line - --or-- with a comment: !line ');
return;
}
this._onChatClientMessageReceived.fire({
userState,
message
});
}
}
private async onDidChangeConfigurationHandler(event: ConfigurationChangeEvent) {
if (event.affectsConfiguration(Configuration.sectionIdentifier) && this.isConnected) {
await this.disconnect();
await this.connect();
}
}
}
================================================
FILE: src/ttvchat/TwitchChatService.ts
================================================
import * as vscode from 'vscode';
import { Commands, Configuration, Settings } from '../enums';
import { HighlighterAPI } from '../index';
import { log, Logger } from '../logger';
import { parseMessage } from '../utils';
import { AuthenticationService } from './AuthenticationService';
import { ChatClient, ChatClientMessageReceivedEvent } from './ChatClient';
export class TwitchChatService implements vscode.Disposable {
private readonly _api: HighlighterAPI;
private readonly _authenticationService: AuthenticationService;
private log: log;
private loginStatusBarItem: vscode.StatusBarItem;
private chatClientStatusBarItem: vscode.StatusBarItem;
private chatClient: ChatClient;
private config?: vscode.WorkspaceConfiguration;
constructor(api: HighlighterAPI, outputChannel: vscode.OutputChannel) {
this.log = new Logger(outputChannel).log;
this.chatClient = new ChatClient(this.log);
this.chatClient.disconnect.bind(this.chatClient);
this.config = vscode.workspace.getConfiguration(Configuration.sectionIdentifier);
this._api = api;
this._authenticationService = new AuthenticationService(this.log);
this._authenticationService.signOutHandler.bind(this._authenticationService);
this.loginStatusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left);
this.loginStatusBarItem.text = `$(sign-in) Twitch`;
this.loginStatusBarItem.command = Commands.signIn;
this.loginStatusBarItem.tooltip = 'Twitch Line Highlighter Login';
this.chatClientStatusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left);
this.chatClientStatusBarItem.text = `$(plug) Disconnected`;
this.chatClientStatusBarItem.command = Commands.connect;
this.chatClientStatusBarItem.tooltip = 'Twitch Line Highlighter Chat Bot';
}
public async initialize(context: vscode.ExtensionContext): Promise {
this.log('ttvchat initializing...');
context.subscriptions.push(
this.loginStatusBarItem,
this.chatClientStatusBarItem,
vscode.workspace.onDidChangeConfiguration(this.onDidChangeConfigurationHandler, this),
this._authenticationService.onAuthStatusChanged(this.onAuthStatusChangedHandler, this),
this.chatClient.onChatClientConnected(this.onChatClientConnectedHandler, this),
this.chatClient.onChatClientMessageReceived(this.onChatClientMessageReceivedHandler, this),
vscode.commands.registerCommand(Commands.signIn, this._authenticationService.signInHandler, this._authenticationService),
vscode.commands.registerCommand(Commands.signOut, this.onSignOutHandler, this),
vscode.commands.registerCommand(Commands.connect, this.chatClient.connect, this.chatClient),
vscode.commands.registerCommand(Commands.disconnect, this.chatClient.disconnect, this.chatClient)
);
this.chatClient.initialize(context);
await this._authenticationService.initialize();
this.log('ttvchat initialized.');
}
private onDidChangeConfigurationHandler(event: vscode.ConfigurationChangeEvent) {
if (!event.affectsConfiguration(Configuration.sectionIdentifier)) {
return;
}
this.config = vscode.workspace.getConfiguration(Configuration.sectionIdentifier);
}
private onAuthStatusChangedHandler(signedIn: boolean) {
if (signedIn) {
this.loginStatusBarItem.hide();
this.chatClientStatusBarItem.show();
}
else {
this.chatClient.disconnect();
this.loginStatusBarItem.show();
this.chatClientStatusBarItem.hide();
}
}
private onChatClientConnectedHandler(isConnected: boolean) {
if (isConnected) {
this.chatClientStatusBarItem.text = '$(plug) Connected';
this.chatClientStatusBarItem.command = Commands.disconnect;
this.chatClientStatusBarItem.tooltip = 'Line Highlighter is connected to the chat room. Click to disconnect.';
}
else {
this.chatClientStatusBarItem.text = '$(plug) Disconnected';
this.chatClientStatusBarItem.command = Commands.connect;
this.chatClientStatusBarItem.tooltip = 'Line Highlighter is not connected to the chat room. Click to connect.';
const unhighlightOnDisconnect = this.config!.get(Settings.unhighlightOnDisconnect) || false;
if (unhighlightOnDisconnect) {
this._api.requestUnhighlightAll('twitch');
}
}
}
private onChatClientMessageReceivedHandler(event: ChatClientMessageReceivedEvent) {
const userName = event.userState.username!;
const result = parseMessage(event.message);
if (result) {
if (result.highlight) {
this._api.requestHighlight('twitch', userName, result.startLine, result.endLine, result.comments);
}
else {
this._api.requestUnhighlight('twitch', userName, result.startLine);
}
}
}
private onSignOutHandler() {
// Disconnect from the chat
this.chatClient.disconnect();
// Sign out of Twitch
this._authenticationService.signOutHandler();
}
public async dispose() {
await this.chatClient.disconnect();
}
}
================================================
FILE: src/ttvchat/api/API.ts
================================================
import * as request from 'request';
import { TwitchKeys } from '../../enums';
export class API {
public static async isUserFollowingChannel(userId: string, channel: string) {
const url = `https://api.twitch.tv/helix/users/followers?from_id=${userId}&to_name=${channel}`;
const result = await new Promise((resolve, reject) => {
request.get(url, {
headers: {
'Accept': 'application/json'
}
}, (err: any, response: any, body: any) => {
if (err) {
reject(err);
}
else {
if (response.statusCode === 200) {
const json = JSON.parse(body);
resolve(true);
}
else {
resolve(false);
}
}
});
});
return result;
}
public static async validateToken(token: string) {
const url = `https://id.twitch.tv/oauth2/validate`;
const result = await new Promise<{valid: boolean, login: string}>((resolve, reject) => {
request.get(url, {
headers: {
'Authorization': `OAuth ${token}`
}
}, (err: any, response: any, body: any) => {
if (err) {
reject(err);
}
else {
if (response.statusCode === 200) {
const json = JSON.parse(body);
resolve({valid: true, login: json.login});
}
else {
resolve({valid: false, login: ''});
}
}
});
});
return result;
}
public static async revokeToken(token: string) {
const url = `https://id.twitch.tv/oauth2/revoke?` +
`client_id=${TwitchKeys.clientId}` +
`&token=${token}`;
const result = await new Promise((resolve, reject) => {
request.post(url, {auth: { 'bearer': token }}, (err, response) => {
if (err) {
reject(err);
}
else {
resolve(response.statusCode === 200);
}
});
});
return result;
}
}
================================================
FILE: src/ttvchat/api/index.ts
================================================
export * from './API';
================================================
FILE: src/ttvchat/index.ts
================================================
export * from './ChatClient';
export * from './TwitchChatService';
================================================
FILE: src/ttvchat/login/index.htm
================================================
You can close the window
================================================
FILE: src/utils/getNodeModule.ts
================================================
import { env } from 'vscode';
import * as keytartype from 'keytar';
declare const __webpack_require__: typeof require;
declare const __non_webpack_require__: typeof require;
export const getNodeModule = (moduleName: string): T | undefined => {
const r = typeof __webpack_require__ === 'function' ? __non_webpack_require__ : require;
try {
return r(`${env.appRoot}/node_modules.asar/${moduleName}`);
}
catch ( err ) {
// Not in ASAR
}
try {
return r(`${env.appRoot}/node_modules/${moduleName}`);
}
catch ( err ) {
// Not available
}
return undefined;
};
================================================
FILE: src/utils/index.ts
================================================
export * from './isEnum';
export * from './naturalCompare';
export * from './getNodeModule';
export * from './parseMessage';
================================================
FILE: src/utils/isEnum.ts
================================================
import { isString, isNumber } from "util";
export const isEnum = (value: any, Enum: {}): boolean => {
if (value === undefined || !isString(value) && !isNumber(value)) {
return false;
}
return Object.values(Enum).includes(value);
};
================================================
FILE: src/utils/naturalCompare.ts
================================================
export const naturalCompare = (a: any, b: any) => {
let ax: any[] = [], bx: any[] = [];
a.replace(/(\d+)|(\D+)/g, function (_: any, $1: any, $2: any) {
ax.push([$1 || Infinity, $2 || ""]);
});
b.replace(/(\d+)|(\D+)/g, function (_: any, $1: any, $2: any) {
bx.push([$1 || Infinity, $2 || ""]);
});
while (ax.length && bx.length) {
let an = ax.shift();
let bn = bx.shift();
let nn = (an[0] - bn[0]) || an[1].localeCompare(bn[1]);
if (nn) {
return nn;
}
}
return ax.length - bx.length;
};
================================================
FILE: src/utils/parseMessage.ts
================================================
export interface ParseMessageResult {
highlight: boolean;
startLine: number;
endLine: number;
fileName?: string;
comments?: string;
}
export const parseMessage = (message: string) => {
/**
* Regex pattern to verify the command is a highlight command
* groups the different sections of the command.
*
* See `https://regexr.com/48gf0` for my tests on the pattern.
*
* Matches:
*
* !line 5
* !line !5
* !line 5-10
* !line !5-15
* !line settings.json 5 | !line 5 settings.json
* !line settings.json !5 | !line !5 settings.json
* !line settings.json 5-15 | !line 5-15 settings.json
* !line settings.json !5-15 | !line !5-15 settings.json
* !line settings.json 5 including a comment | !line 5 settings.json including a comment
* !line settings.json 5-15 including a comment | !line 5-15 settings.json including a comment
* !line settings.json 5 5 needs a comment | !line 5 settings.json 5 needs a comment
* !line 5 5 needs a comment
* !line 5-7 6 should be deleted
* !line settings.json 5-7 6 should be deleted
* !highlight 5
*
*/
const commandPattern = /\!(?:line|highlight) (?:((?:[\w]+)?\.?[\w]*) )?(\!)?(-?\d+)(?:(?:-{1}|\.{2})(-?\d+))?(?: ((?:[\w]+)?\.[\w]{1,}))?(?: (.+))?/i;
const cmdopts = commandPattern.exec(message);
if (!cmdopts) {
return undefined;
}
const highlight = cmdopts[2] === undefined;
const fileName = cmdopts[1] || cmdopts[5];
const startLine = +cmdopts[3];
const endLine = cmdopts[4] ? +cmdopts[4] : +cmdopts[3];
const comments = cmdopts[6];
const vStartLine = endLine < startLine ? endLine : startLine;
const vEndLine = endLine < startLine ? startLine : endLine;
const result: ParseMessageResult = {
highlight,
startLine: vStartLine,
endLine: vEndLine,
fileName,
comments: comments
};
return result;
};
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"module": "commonjs",
"target": "es6",
"outDir": "out",
"lib": [
"es6",
"es2017"
],
// "declaration": false,
// "declarationDir": "types",
"sourceMap": true,
"rootDir": "src",
/* Strict Type-Checking Option */
"strict": true, /* enable all strict type-checking options */
/* Additional Checks */
// "noUnusedLocals": true /* Report errors on unused locals. */,
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
"skipLibCheck": true /* Skip type checking of all declaration files */
},
"exclude": [
"node_modules",
".vscode-test",
"types",
"out"
]
}
================================================
FILE: tslint.json
================================================
{
"rules": {
"no-string-throw": true,
"no-unused-expression": true,
"no-duplicate-variable": true,
"curly": true,
"class-name": true,
"semicolon": [
true,
"always"
],
"triple-equals": true
},
"defaultSeverity": "warning",
"linterOptions": {
"exclude": [
"node_modules"
]
}
}
================================================
FILE: webpack.config.js
================================================
'use strict';
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const CopyPlugin = require('copy-webpack-plugin');
module.exports = {
target: 'node',
entry: {
extension: './src/extension.ts',
ttvchat: './src/ttvchat/index.ts',
},
devtool: 'source-map',
plugins: [
new CleanWebpackPlugin(),
new CopyPlugin({
patterns: [
{ from: 'src/ttvchat/login', to: 'ttvchat/login' }
]
})
],
module: {
rules: [
{
test: /\.ts$/,
exclude: /node_modules/,
use: 'ts-loader',
},
]
},
externals: {
// Exclude vscode from webpack since it is generated automatically
vscode: 'commonjs vscode',
},
resolve: {
extensions: ['.ts', '.js']
},
output: {
path: path.resolve(__dirname, 'out'),
filename: '[name].js',
libraryTarget: 'commonjs2',
devtoolModuleFilenameTemplate: '../[resource-path]'
}
};